ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Python Flask的身份证识别系统:PaddleOCR与OpenCV完整实现

基于Python Flask的身份证识别系统:PaddleOCR与OpenCV完整实现 简介一份基于 Python Flask 的身份证识别系统毕业设计源码包面向计算机相关专业毕业生与 Flask 入门开发者。项目采用前后端分离思路后端以 Flask 提供数据接口前端使用 HTML5、CSS3、JavaScript 及 jQuery、Bootstrap 构建页面并通过 Ajax 调用接口覆盖摄像头识别、图片识别、识别结果持久化到 MySQL5.7 等完整流程。压缩包共 28 个文件约 1.06MB核心包含 6 个 Python 源文件、4 个 HTML 页面、1 份 SQL 数据库脚本及配置文件、依赖清单另有若干图片、样式与脚本资源目录按 db、static、templates 等模块划分便于快速部署与二次开发。目前已有 323 人学习下载。从源码中可掌握 Flask 路由与接口设计、OpenCV 摄像头调用、Ajax 前后端交互、数据库读写等关键技能适合用于课程设计、毕业答辩或实际项目改造。1. 基于 Python Flask 的身份证识别系统重点不在 Flask身份证识别系统是本科毕设里出现频率极高的题目它同时覆盖 Web 开发、数据库设计、图像处理三个知识点工作量适中演示效果直观。但难度分布很不均匀Flask 路由和数据库增删改查只占三成剩下七成在 OCR 链路和字段解析上。能跑的 demo 和答辩不出错的系统差别往往在几个细节OCR 引擎选型、图片预处理、18 位号码校验码、SQLite 迁 MySQL 的配置切换。下文按我实际搭建的顺序展开从技术选型到数据库落地最后给答辩前的调试和部署经验代码可直接改造成你自己的毕业设计源代码。2. 技术选型与 Flask 项目骨架先定 OCR 引擎再写路由很多人习惯先建 Flask 工程、把路由写出来再回头找 OCR 库这个顺序我建议反过来。识别效果八成由 OCR 引擎和图片预处理决定Flask 只负责串流程。引擎定不下来后面整个接口的返回字段都要返工。2.1 PaddleOCR 与 Tesseract 怎么选中文识别的两个主流方案先看选型对比这决定项目代码量和工作量分配。对比项PaddleOCRTesseractchi_sim中文识别效果印刷体整体识别率高适合证件类一般身份证这类紧凑排版容易串行丢字安装成本pip 安装模型首次运行自动下载占用 1~2GB需安装系统级 tesseract 二进制再装 chi_sim 语言包Windows 下 PATH 配置麻烦返回内容文本 坐标框 置信度文本为主取坐标要额外处理CPU 单张耗时2~5 秒1 秒左右答辩可讲深度检测 识别两阶段可展开讲原理偏工具调用内容单薄我一般的结论答辩演示优先 PaddleOCR识别稳定性和可讲深度都占优如果答辩机内存小于 4GB再考虑退到 pytesseract。这里有个高频坑PaddleOCR 2.x 和 3.x 的 API 结构完全不同网上大量参考代码是 2.x 写法直接复制到 3.x 会报错。我的做法是锁定版本paddlepaddle2.6.0paddleocr2.7.0这个组合在 Windows 和 Linux 上被验证得最多资料也最好找。Python 版本方面3.8~3.10 是比较稳的区间别用 3.12 追新PaddlePaddle 对最新解释器的支持有滞后装不上是常事。2.2 Flask 工程结构与上传接口最小可运行骨架能跑起来演示的最小工程这样组织目录刻意简化方便后面画进论文架构图idcard_system/ ├── app.py # Flask 应用、路由 ├── config.py # 数据库与上传配置 ├── models.py # SQLAlchemy 模型 ├── ocr_utils.py # OCR 封装、字段解析、号码校验 ├── uploads/ # 上传图片落盘目录 ├── templates/ │ └── index.html ├── static/ └── requirements.txtrequirements.txt 这样写flask3.0.0 flask-sqlalchemy3.1.1 pymysql1.1.0 opencv-python4.9.0.80 paddlepaddle2.6.0 paddleocr2.7.0两个说明pymysql 是后面接 MySQL 的驱动SQLite 阶段用不到但也先装上opencv-python 别追新4.9 与 PaddleOCR 2.7 的依赖解析冲突最少装最新版很容易把 numpy 版本顶掉。app.py 先跑通骨架路由只实现上传和回显识别逻辑下一章再接from flask import Flask, request, jsonify, render_template import os, uuid app Flask(__name__) app.config[UPLOAD_FOLDER] os.path.join(os.path.dirname(__file__), uploads) app.config[MAX_CONTENT_LENGTH] 10 * 1024 * 1024 # 单次请求体上限 10MB os.makedirs(app.config[UPLOAD_FOLDER], exist_okTrue) ALLOWED_EXT {png, jpg, jpeg, bmp, webp} app.route(/) def index(): return render_template(index.html) app.route(/api/recognize, methods[POST]) def recognize(): file request.files.get(file) if not file or . not in file.filename: return jsonify({code: 1, message: 未选择文件}), 400 ext file.filename.rsplit(., 1)[1].lower() if ext not in ALLOWED_EXT: return jsonify({code: 1, message: 仅支持图片文件}), 400 # 用 uuid 重命名避免中文文件名在 werkzeug 里被过滤成空串 save_name f{uuid.uuid4().hex}.{ext} save_path os.path.join(app.config[UPLOAD_FOLDER], save_name) file.save(save_path) return jsonify({code: 0, data: {saved_name: save_name}}) if __name__ __main__: app.run(host0.0.0.0, port5000, debugTrue)这段有三个细节值得说。MAX_CONTENT_LENGTH超限时 Flask 直接返回 413不用自己判断文件大小但前端最好也同步限制一次。secure_filename我特意没用因为它会把中文文件名处理成空字符串导致保存路径变成空所以改用 uuid 重命名顺带避免同名文件互相覆盖。host0.0.0.0让同一局域网设备能访问笔记本服务答辩时可以拿手机演示只本机跑可以去掉。2.3 PaddleOCR 单例封装别在每次请求里加载模型PaddleOCR 初始化要加载检测、识别、方向分类三个模型耗时 3~10 秒。写进路由函数的话每次识别都要等一次加载接口响应时间直接无法接受。正确做法是模块级缓存单例。# ocr_utils.py from paddleocr import PaddleOCR _ocr None def get_ocr(): global _ocr if _ocr is None: _ocr PaddleOCR(use_angle_clsTrue, langch, show_logFalse) return _ocruse_angle_clsTrue开启方向分类身份证照片转了 90 度或 180 度也能纠正langch加载简体中文模型show_logFalse关掉启动横幅和日志刷屏。第一次调用会联网下载模型文件并缓存到用户目录之后离线可用。答辩前务必在自己电脑上先跑一张图把模型下载好免得现场等下载或者网络受限直接卡死。提示Flask 开发服务器是单进程多线程模块级单例在这里没问题换 gunicorn 多 worker 后每个 worker 各有一份模型副本内存按 worker 数翻倍2GB 内存的服务器建议-w 1。3. 身份证图像识别与字段解析预处理、坐标框与 18 位校验这一章是系统核心。完整链路四步预处理 → OCR → 字段解析 → 号码校验每步都有可以提前规避的坑。3.1 OpenCV 预处理矫正比二值化更值得花时间很多教程一上来就灰度化加二值化这是老式 OCR 的惯性。对 PaddleOCR 来说过度二值化会丢失笔画细节、拉低识别率。真正值得做的是控制尺寸和透视矫正。手机拍身份证大多有轻微倾斜不矫正的话底部号码行容易出现字符粘连。import cv2 import numpy as np def align_idcard(img): 检测身份证外轮廓并矫正为正面视角返回灰度图 if img is None: return None h, w img.shape[:2] # 长边超过 1400 就缩放图太大反而慢且容易漏字 if max(h, w) 1400: scale 1400 / max(h, w) img cv2.resize(img, None, fxscale, fyscale, interpolationcv2.INTER_AREA) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) edges cv2.Canny(gray, 50, 150) # 低/高双阈值边缘检测 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) closed cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel) # 闭运算连接断裂边缘 contours, _ cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours sorted(contours, keycv2.contourArea, reverseTrue) for c in contours[:5]: peri cv2.arcLength(c, True) approx cv2.approxPolyDP(c, 0.02 * peri, True) if len(approx) 4: # 找到四边形就做透视变换 rect approx.reshape(4, 2).astype(float32) return four_point_transform(gray, rect) return grayCanny的 50 和 150 是低、高阈值低于 50 的梯度不算边缘高于 150 的必然算两者之间的看连通性。证件照背景简单这个经验值够用。morphologyEx闭运算用 5x5 矩形核修补身份证边缘断裂处。approxPolyDP的0.02 * peri是逼近精度比例越小越严格标准矩形用 0.02 比较稳。透视变换要固定四个角点的顺序否则矫正出来是旋转或镜像的def four_point_transform(image, pts): rect np.zeros((4, 2), dtypefloat32) s pts.sum(axis1) rect[0] pts[np.argmin(s)] # 左上角 xy 最小 rect[2] pts[np.argmax(s)] # 右下角 xy 最大 d np.diff(pts, axis1).reshape(-1) rect[1] pts[np.argmin(d)] # 右上角 y-x 最小 rect[3] pts[np.argmax(d)] # 左下角 y-x 最大 tl, tr, br, bl rect widthA int(np.linalg.norm(br - bl)) widthB int(np.linalg.norm(tr - tl)) maxWidth max(widthA, widthB) heightA int(np.linalg.norm(tr - br)) heightB int(np.linalg.norm(tl - bl)) maxHeight max(heightA, heightB) dst np.array([[0, 0], [maxWidth - 1, 0], [maxWidth - 1, maxHeight - 1], [0, maxHeight - 1]], dtypefloat32) M cv2.getPerspectiveTransform(rect, dst) return cv2.warpPerspective(image, M, (maxWidth, maxHeight))排序逻辑一句话sum(axis1)对每个点求 xy左上最小、右下最大np.diff求 y-x右上最小、左下最大。这套排序成立的前提是轮廓检测确实拿到了身份证的四个真实角点所以前面轮廓找得准不准直接决定矫正成败。提示如果发现矫正后识别率反而下降多半是轮廓检测把背景里的其他矩形当成了身份证。先打印len(contours)和面积排序结果排查别直接怀疑透视变换写错了。3.2 兼容 PaddleOCR 2.x 与 3.x 的文本提取PaddleOCR 2.x 的ocr.ocr(img, clsTrue)返回嵌套列表外层对应输入图内层每行是[坐标框, (文本, 置信度)]。3.x 改用predict接口返回结果按字典方式取rec_texts。两个版本不兼容为了换环境不翻车我封装一层提取函数def extract_texts(ocr_result): 从 PaddleOCR 结果中提取文本行兼容 2.x / 3.x texts [] if isinstance(ocr_result, list): for img_lines in ocr_result: if not img_lines: continue for line in img_lines: if line is None: continue try: texts.append(line[1][0]) # 2.x: line [box, (text, score)] except (IndexError, TypeError): pass if not texts: items ocr_result if isinstance(ocr_result, list) else [ocr_result] for item in items: # 3.x 的 OCRResult 是类字典对象用 hasattr 兜底 rec_texts item.get(rec_texts) if hasattr(item, get) else None if rec_texts: texts.extend(rec_texts) return texts思路是先按 2.x 的嵌套结构取line[1][0]取不到再按 3.x 的字典结构读rec_texts两边各走各的分支。实际调试时比封装更重要的是先探一次结构print(type(result), result[:2])看清楚你的版本到底返回什么比对着文档猜省时间得多。3.3 字段解析与 18 位号码校验码OCR 输出是杂乱的文本行逐行匹配关键词是最快路径因为新版身份证正面每个字段都有固定排版关键词。解析函数这样写import re def parse_idcard_fields(text_lines): info {name: , gender: , ethnicity: , birth_date: , address: , id_number: } id_pattern re.compile(r\d{17}[\dXx]) addr_parts [] for line in text_lines: line line.strip().replace( , ) if 姓名 in line: info[name] line.split(姓名)[-1] if 性别 in line and 民族 in line: seg line.split(性别)[-1] info[gender] seg[0] # “性别 男 民族 汉”里取男 info[ethnicity] seg.split(民族)[-1].strip() if 出生 in line: info[birth_date] re.sub(r\D, , line.split(出生)[-1]) if 住址 in line: addr_parts.append(line.split(住址)[-1]) m id_pattern.search(line) if m: info[id_number] m.group().upper() info[address] .join(addr_parts) return info性别和民族通常在同一行必须按性别切完取第一个字符再按民族切顺序反了会串值。出生行提取纯数字1990年1月1日变成199011展示字段这样够用。号码正则\d{17}[\dXx]匹配 18 位末位 X 统一转大写OCR 常把 X 误读成×或0这个坑留给校验码兜底。校验码算法是答辩最可能被当场追问的硬点def check_id_number(id_number): 校验 18 位身份证号码校验码X 视为大写 if len(id_number) ! 18 or not id_number[:17].isdigit(): return False weights [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2] check_codes 10X98765432 total sum(int(id_number[i]) * weights[i] for i in range(17)) return check_codes[total % 11] id_number[-1].upper()这套加权取模来自 ISO 7064:1983 MOD 11-2 算法。前 17 位分别乘权重因子求和对 11 取模模值映射到一个 11 字符的校验序列上。它能拦掉大多数 OCR 字符误读末位 X 读成 0、中间位数字看错校验基本都会失败。失败时接口直接返回号码校验失败请重新拍摄比把脏数据存进数据库再人工清理强。加一道日期合理性检查更稳取id_number[6:14]判断月份 01~12、日期 01~31防止 OCR 把相邻字符拼进号码段。4. 数据库设计与 Flask 数据层从 SQLite 平滑迁到 MySQL毕业设计的交付物里源代码和数据库脚本是分开的两块。数据库不建议一开始就在 MySQL 上开发SQLite 零配置跑通全流程答辩部署时再切 MySQL这是这类项目最稳的节奏。4.1 识别记录与用户表分开设计表结构这样建很多开源成品只建一张表存识别记录演示能过答辩一问权限设计就露怯。我建议至少两张表user管登录idcard_record管识别记录。CREATE TABLE user ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(64) NOT NULL UNIQUE, password_hash VARCHAR(128) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE idcard_record ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(32) NOT NULL, gender VARCHAR(4) DEFAULT , ethnicity VARCHAR(16) DEFAULT , birth_date VARCHAR(16) DEFAULT , address VARCHAR(255) DEFAULT , id_number CHAR(18) NOT NULL, image_path VARCHAR(255) DEFAULT , create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_id_number (id_number), KEY idx_create_time (create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个设计决策说清楚。id_number用CHAR(18)而非VARCHAR(18)定长字符串在 InnoDB 的索引比较上更高效。UNIQUE KEY让同一身份证号不能重复入库配合应用层捕获IntegrityError接口天然具备幂等性。create_time由数据库生成不依赖应用层传时间。性别、民族默认空字符串而不是 NULL前端渲染省一堆空值判断。charset 用 utf8mb4 而不是 utf8MySQL 里的 utf8 实际是 utf8mb3存全量字符有风险。密码字段存password_hash而不是明文这是答辩的高频追问点。用 werkzeug 的generate_password_hash加盐哈希登录时check_password_hash比对代码里不出现任何明文密码。4.2 SQLAlchemy 模型与查询接口分页和模糊搜索是必考项models.py 这样写from flask_sqlalchemy import SQLAlchemy db SQLAlchemy() class User(db.Model): __tablename__ user id db.Column(db.Integer, primary_keyTrue, autoincrementTrue) username db.Column(db.String(64), uniqueTrue, nullableFalse) password_hash db.Column(db.String(128), nullableFalse) class IdCardRecord(db.Model): __tablename__ idcard_record id db.Column(db.Integer, primary_keyTrue, autoincrementTrue) name db.Column(db.String(32), nullableFalse) gender db.Column(db.String(4), default) ethnicity db.Column(db.String(16), default) birth_date db.Column(db.String(16), default) address db.Column(db.String(255), default) id_number db.Column(db.String(18), nullableFalse, uniqueTrue, indexTrue) image_path db.Column(db.String(255), default) create_time db.Column(db.DateTime, server_defaultdb.func.now()) def to_dict(self): 转成可 JSON 序列化的字典供前端展示 return { id: self.id, name: self.name, gender: self.gender, ethnicity: self.ethnicity, birth_date: self.birth_date, address: self.address, id_number: self.id_number, image_path: self.image_path, create_time: self.create_time.strftime(%Y-%m-%d %H:%M:%S) if self.create_time else }识别接口里保存记录时先校验号码再写库碰到重复号码要回滚事务from sqlalchemy.exc import IntegrityError # recognize 路由中OCR 解析完成后 if not check_id_number(info[id_number]): return jsonify({code: 2, message: 号码校验失败请重新拍摄}) record IdCardRecord( nameinfo[name], genderinfo[gender], ethnicityinfo[ethnicity], birth_dateinfo[birth_date], addressinfo[address], id_numberinfo[id_number], image_pathsave_path ) db.session.add(record) try: db.session.commit() except IntegrityError: db.session.rollback() return jsonify({code: 2, message: 该身份证已存在}) return jsonify({code: 0, data: record.to_dict()})IntegrityError是唯一键冲突的入口重复识别同一张证不会产生冗余记录这是答辩演示时最容易被观察到的专业行为。历史记录页的查询接口分页和模糊搜索一次给全app.route(/api/records) def records(): page request.args.get(page, 1, typeint) size request.args.get(size, 10, typeint) kw request.args.get(kw, , typestr).strip() query IdCardRecord.query if kw: like f%{kw}% query query.filter(db.or_( IdCardRecord.name.like(like), IdCardRecord.id_number.like(like))) pagination query.order_by(IdCardRecord.create_time.desc()).paginate( pagepage, per_pagesize, error_outFalse) return jsonify({ code: 0, rows: [r.to_dict() for r in pagination.items], total: pagination.total })args.get(page, 1, typeint)自带类型转换非法参数自动落回默认值不会抛 400。error_outFalse很关键超过最大页数时返回空列表而不是 404前端分页组件不用写异常分支。db.or_拼接姓名和身份证号的模糊搜索注意kw先strip()不然搜索框带空格会查出空结果。删除接口顺手把本地图片一起删掉避免 uploads 目录无限膨胀app.route(/api/records/int:rid, methods[DELETE]) def delete_record(rid): record db.session.get(IdCardRecord, rid) if record is None: return jsonify({code: 1, message: 记录不存在}), 404 if record.image_path and os.path.exists(record.image_path): os.remove(record.image_path) db.session.delete(record) db.session.commit() return jsonify({code: 0})db.session.get()是 Flask-SQLAlchemy 3.x 按主键查询的推荐写法替代旧版Query.get()写代码时先确认自己装的是 2.x 还是 3.x别混用。图片删除前用os.path.exists判断避免文件已被手动移走时报错。4.3 环境变量切数据库SQLite 和 MySQL 一套代码跑通配置独立成 config.py用一个环境变量来回切import os BASE_DIR os.path.dirname(os.path.abspath(__file__)) class Config: SECRET_KEY os.getenv(SECRET_KEY, dev-secret-key-change-me) SQLALCHEMY_TRACK_MODIFICATIONS False # 默认 SQLite答辩演示零依赖 SQLALCHEMY_DATABASE_URI sqlite:/// os.path.join(BASE_DIR, idcard.db) class MySQLConfig(Config): SQLALCHEMY_DATABASE_URI ( mysqlpymysql://root:password127.0.0.1:3306/idcard_db ?charsetutf8mb4 )app.py 里按环境变量装载配置并把建表逻辑放进 CLI 命令app.config.from_object(MySQLConfig if os.getenv(DB) mysql else Config) db.init_app(app) app.cli.command(init-db) def init_db(): db.create_all() print(数据表创建完成)本地跑flask --app app init-db用 SQLite服务器上设DBmysql flask --app app init-db切到 MySQL。放在 CLI 命令里而不是应用启动时自动create_all好处是生产环境不会每次重启都执行建表语句也更符合答辩时初始化流程清晰的印象。5. Flask 项目部署与答辩验证断点不生效、gunicorn 与批量脚本5.1 PyCharm 断点不生效Flask reloader 与调试器冲突当前不会命中断点是 PyCharm 里排查 Flask 项目最常见的报错。根因是debugTrue会启动 reloader 子进程PyCharm 调试器 attach 到父进程断点自然落空。两个解法调试时改app.run(debugTrue, use_reloaderFalse)或者干脆debugFalse只靠断点调试。顺带检查 PyCharm 的 Project Interpreter 是否指向你建好的虚拟环境装完 Flask 之后换解释器同样会导致 import 异常。用 VS Code 的同学同理先选对解释器再启动调试面板否则终端能跑、调试面板却报找不到模块。5.2 部署到 Linuxgunicorn 替代 Flask 自带服务器Flask 自带服务器是开发服务器不适合直接对外。常见做法是 gunicorn 加 nginxpip install gunicorn gunicorn -w 1 -b 127.0.0.1:8000 app:app-w 1不是偷懒PaddleOCR 模型在内存里占了将近 1GB多 worker 内存翻倍毕设项目单 worker 完全够。nginx 侧配置反向代理server { listen 80; client_max_body_size 10m; location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /static/ { alias /opt/idcard_system/static/; } }client_max_body_size要和 Flask 的MAX_CONTENT_LENGTH对齐nginx 默认只放行 1MB不上传大图基本必踩这个 413。uploads 目录记得给运行用户写权限很多部署问题最后都是权限。5.3 批量验证脚本与演示数据的隐私处理答辩前写一个接口批量验证脚本把每张测试图的识别结果和校验码打印出来批量验证识别接口循环调用 /api/recognize 并打印校验结果 import requests from ocr_utils import check_id_number BASE http://127.0.0.1:5000 samples [samples/sample1.jpg, samples/sample2.jpg, samples/sample3.jpg] for path in samples: with open(path, rb) as fp: resp requests.post(f{BASE}/api/recognize, files{file: fp}) data resp.json() if data[code] ! 0: print(path, 接口失败:, data[message]) continue info data[data] ok check_id_number(info[id_number]) print(path, info[name], info[id_number], 校验 (通过 if ok else 失败))把脚本放进/test目录samples 放 3~5 张不同角度、不同背景的测试图答辩前跑一遍结果稳定再上台。演示数据不要用真实身份证照片用脱敏样例或程序生成的测试图片论文里也写明数据仅用于功能验证。真证件照片一旦随源码包流传出去问题比答辩不过严重得多。注意身份证属于敏感个人信息演示、截图和上传到公开仓库的代码都要用打码或无实名的样例数据库里也不要出现真实号码。这些准备做完剩下要做的就是当着评委的面用同一批测试图把识别、入库、查询、删除四个动作重演一遍别只放 PPT。本文还有配套的精品资源点击获取
返回列表