ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python康复评估系统:从评分引擎到FastAPI部署与趋势分析

Python康复评估系统:从评分引擎到FastAPI部署与趋势分析

简介:一套基于Python实现的康复评估系统源码与配套数据集,主要面向计算机、自动化、电子信息等专业学生用于毕业设计、课程设计或项目初期演示,也适合Python进阶者参考。压缩包内共157个文件,包含70个csv数据文件、68个bvh动作捕捉数据、11个py源码文件、2个ipynb演示笔记及其他辅助文件,整体约16.18MB。bvh文件记录了不同受试者的康复动作序列,csv可对应评估指标与样本标签,py源码覆盖数据预处理、骨骼特征计算、评估模型构建与结果可视化等流程,ipynb则提供分步演示,便于理解和二次开发。目前已有260人学习下载。压缩包经过测试上传,代码运行正常且目录结构清晰,既能支撑康复评估功能的快速落地,也方便在此基础上调整算法、改造界面或扩展新模块,适合多元化的毕业设计与课程实践需求。

1. 用 Python 做的康复评估系统源码,先把纸质评估表改造成结构化数据

一个基于 Python 的康复评估系统源码,配着数据集一起给你,解压后你得到的不是一份“能跑就行”的 Demo,而是一套可以真正接进康复科日常流程的评估工具。我见过太多康复科室还在用纸质 FMA、Barthel 表,治疗师手填、计算器算总分、再对着 Excel 翻历史对比,前后数据版本还对不上。这个项目解决的就是这件事:把康复评估里最核心的量表、评分、分级、报告和随访趋势,做成本地可运行的 Python 服务,配套的数据 CSV 可以直接导入数据库当成初始评估记录。适合康复科软件开发者、医疗信息化外包团队,以及想拿医疗 Python 项目练手的人。

2. 从 FMA、BBS 到 Barthel:把康复量表翻译成可执行的评分引擎

康复评估系统的第一道坎,不是界面好不好看,而是评分规则能不能在代码里立住。康复医学里的量表看起来就是打几分、加个总,实际上每一个量表都有自己的条目数量、分值集合、版本差异和分级区间。你如果一上来就写if total >= 90: return "轻度",后面换一个量表就全乱。常见做法是先做一个“模板化评估引擎”,让每个量表都变成一份可配置的数据结构,再让引擎统一处理算分、校验和分级。

2.1 康复评估系统的核心:一个能处理“0/1/2 打分”的评估引擎

拿 Fugl-Meyer 运动功能评估(FMA)举例,它把运动功能分成上肢 33 项、下肢 17 项,每一项按 0、1、2 三档打分,上肢满分 66、下肢满分 34,合计 100。这个结构如果用一堆 if-else 写死在视图里,后期维护就是灾难。我一般会先定义一份评估模板,把条目 ID、题目文本、允许分值绑在一起:

# 一个最小可运行的 FMA 运动功能评估模板 from dataclasses import dataclass @dataclass class ScaleTemplate: scale_id: str # 量表唯一标识,比如 "fma" scale_name: str # 展示名称,比如 "FMA 运动功能版" items: dict # 条目ID -> (题目文本, 允许的分值集合) max_total: int # 总分上限,用于进度条和报告展示 grading: list # 分级规则,按阈值降序排列 [(最小总分, 级别)] FMA_ITEMS = { "ue_01": ("肩反射活动", (0, 1, 2)), "ue_02": ("屈肌共同运动:肩后缩上提", (0, 1, 2)), "ue_03": ("屈肌共同运动:肩外展≥90°", (0, 1, 2)), # 其余 30 项遵循同样模式,完整条目建议单独放 items.py 里维护 }

模板建好之后,评分引擎只需要做三件事:遍历条目、检查分值合法性、累加总分并分级。下面这个score_scale函数可以在所有量表之间复用,而不是每个量表写一套算法:

def score_scale(template: ScaleTemplate, results: dict) -> dict: """results 的格式是 {条目ID: 分值},返回总分、分级和逐项错误列表。""" errors = [] total = 0 for item_id, (text, allowed_scores) in template.items.items(): value = results.get(item_id) if value is None: errors.append(f"{item_id} 未作答") elif value not in allowed_scores: errors.append(f"{item_id} 不允许分值 {value},允许 {allowed_scores}") else: total += value if errors: return {"ok": False, "errors": errors} level = "未知" for threshold, label in template.grading: if total >= threshold: level = label break return {"ok": True, "total": total, "level": level}

这段代码的逻辑很直白,但有两个参数细节要注意。第一是grading必须按阈值降序排列,比如先判>= 96再判>= 85,否则低于阈值的分数会落进错误的级别。第二是条目 ID 的命名,建议用ue_01这种定长格式而不是ue_1,否则在字典序排序显示时会出现ue_10排在ue_2前面的幻觉,虽然不影响求和,但治疗师看检查表时会懵。

2.2 让模板可扩展:Berg 平衡量表 56 分与 Barthel 100 分的分值集合

FMA 的每条分值都是 0/1/2,这是最规整的情况。真正让人踩坑的是 Barthel 指数这类“分值集合不统一”的量表。Barthel 一共 10 项,有的项是 0/5,有的项是 0/5/10,有的项还有 15 分档,比如“床椅转移”和“平地行走”就是 0/5/10/15。如果你在代码里只设一个总分上限 100,那录入端就有可能把“洗澡”填成 10 分,总分算出 105 还没报错,治疗师当场就能发现不对。

所以在模板数据结构里,我坚持为每个条目单独存一份允许分值集合:

BARTHEL_ITEMS = { "ba_01": ("进食", (0, 5, 10)), "ba_02": ("洗澡", (0, 5)), "ba_03": ("修饰", (0, 5)), "ba_04": ("穿衣", (0, 5, 10)), "ba_05": ("大便控制", (0, 5, 10)), "ba_06": ("小便控制", (0, 5, 10)), "ba_07": ("如厕", (0, 5, 10)), "ba_08": ("床椅转移", (0, 5, 10, 15)), "ba_09": ("平地行走", (0, 5, 10, 15)), "ba_10": ("上下楼梯", (0, 5, 10)), } BBS_ITEMS = { "bbs_01": ("从坐位站起", (0, 1, 2, 3, 4)), "bbs_02": ("独立站立", (0, 1, 2, 3, 4)), "bbs_03": ("闭眼站立", (0, 1, 2, 3, 4)), # BBS 共 14 项,每项 0-4 分,总分 56 }

这里的“参数说明”其实就一句话:每一个条目的分值集合必须和原版量表逐项核对,别相信网上转了好几手的表。Berg 平衡量表相对省心,14 项全是 0 到 4 分,总分 56,按 0-20 高跌倒风险、21-40 中度风险、41-56 低风险来分级。Barthel 就必须一项一项对,尤其“洗澡”这一项,原版只有 0 和 5,不存在 10 分档。

当每个条目的分值集合进了模板之后,前端就可以直接拿这份配置生成打分控件,后端再用同一个配置做二次校验,非法值在入口就被拦住,而不是等总分算出来才发现不对劲。

2.3 把分级存进配置:为什么不要硬编码

我在前面的代码里把grading放进模板,这个习惯是从一次翻车里学来的。当时我把 FMA 的分级阈值直接写死在视图函数里,三个月后康复科主任说“我们这里的患者人群偏重,96 分轻度这个阈值要往下降”,我只好满项目搜96这个数字。如果你一开始就把分级规则放进配置,比如scale_config.json,那治疗师调阈值就是改一个数字的事:

{ "fma": { "max_total": 100, "grading": [[96, "轻度运动障碍"], [85, "中度运动障碍"], [50, "明显运动障碍"], [0, "严重运动障碍"]] }, "bbs": { "max_total": 56, "grading": [[41, "低跌倒风险"], [21, "中度风险"], [0, "高风险"]] }, "barthel": { "max_total": 100, "grading": [[61, "轻度依赖"], [41, "中度依赖"], [21, "重度依赖"], [0, "完全依赖"]] } }

分级阈值在不同康复机构、不同患者群里差异很大,源码包里给的是通用参考值,上线前一定要让治疗师按自己科室的规范确认一遍。

提示:FMA 的“96 分轻度、85 分中度、50 分明显、50 以下严重”只是常用分段,不是教科书唯一标准。做系统时把默认值写进配置,报告里标注“按科室共识版本”,比留一个神秘数字体面得多。

这一层做完,评分引擎已经能处理绝大多数量表了。关键在于“模板驱动”:新增一个量表,不需要动引擎代码,只需要加一份模板数据,然后注册到量表字典里。

3. 源码包怎么跑起来:FastAPI 后端、SQLite 存储与前端的职责划分

评分引擎只是系统的心脏,外面还得裹上 API、存储和页面。这个源码包里最常见的技术栈是 FastAPI + SQLAlchemy + SQLite,前端直接用 HTML 加 fetch 调用,不引入重型框架。选这个组合的理由很现实:康复评估系统通常部署在科室一台 Windows 电脑上,数据量不大,SQLite 零配置、单文件备份,FastAPI 能在一个文件里把接口和静态页面都托管起来,部署成本最低。

3.1 解压后常见的目录结构:哪一层放业务,哪一层放数据

我拿到这种源码包第一件事不是跑起来,而是看目录结构。一个结构清楚的康复评估系统,通常会按下面这种方式分层:

RehabAssessment/ ├── app/ │ ├── main.py # FastAPI 入口,注册路由和中间件 │ ├── models.py # SQLAlchemy 表结构 │ ├── schemas.py # Pydantic 请求/响应模型 │ ├── services/ │ │ ├── scale.py # 评估引擎,上一章的模板+评分逻辑 │ │ ├── trend.py # 康复趋势整理 │ │ └── rom.py # 关节活动度计算,进阶模块 │ ├── api/ │ │ ├── patients.py │ │ └── assessments.py │ └── static/ # 前端页面与 JS ├── data/ # 数据集 CSV ├── tests/ # 引擎单测 ├── requirements.txt └── README.md

分层最关键的一条是:services里只放纯业务,不出现request、response这些词;api层只做参数接收、调用服务和拼装响应。这样评分引擎可以被单元测试直接调用,不用启动服务。很多人把业务写在路由函数里,结果每个接口都在重复算分逻辑,换量表时候改到崩溃。

3.2 跑通最小链路:提交评估答案到拿到结构化报告

入口文件main.py一般不复杂,但有几个配置位要注意。开发期一定要先把 CORS 中间件打开,不然后端写完了前端页面照样调不通,具体坑在第 5 章讲。这里先看最小入口:

from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware app = FastAPI(title="rehab-assessment-api") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], )

代码里allow_origins=["*"]是开发期最省事的写法,生产环境要改成具体域名。接着看评估提交接口,这是整个系统的关键链路:前端把患者 ID、量表 ID、评估日期和所有条目的答案传进来,后端调用评分引擎,通过后写入数据库:

from pydantic import BaseModel class AssessmentIn(BaseModel): patient_id: str scale_id: str assessment_date: str # 统一传 "YYYY-MM-DD" results: dict # {条目ID: 分值} @app.post("/api/assessments") def create_assessment(data: AssessmentIn): template = scale_templates.get(data.scale_id) if template is None: return {"ok": False, "reason": f"未知量表 {data.scale_id}"} scored = score_scale(template, data.results) if not scored["ok"]: return {"ok": False, "errors": scored["errors"]} # 入库:一次评估会话 + 若干 detail 行,用事务提交 return {"ok": True, "total": scored["total"], "level": scored["level"]}

这里 Pydantic 的results字段直接接收字典,省去了逐个参数声明的啰嗦,但代价是字段合法性得靠评分引擎自己查。所以顺序很重要:先校验、再算分、最后入库,千万别先落库再校验,否则脏数据进去就不好洗了。

启动服务的命令也是固定套路,先建虚拟环境再装依赖:

cd RehabAssessment python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -r requirements.txt uvicorn app.main:app --reload --port 8000

这里--reload只在开发期用,生产部署去掉;如果科室电脑上有别的服务占着 8000 端口,改--port 8001就行。前端页面如果要让局域网里的平板访问,启动时加--host 0.0.0.0,否则默认只监听本机,病房里的平板打不开。

3.3 表结构怎么建:patient 与 assessment 的核心字段

数据库结构是这个项目最值得抄作业的部分。康复评估数据的特点是:一个患者多次评估,每次评估有多个条目,条目数随量表变化。所以用三张表最稳:患者表存基本信息,评估会话表存“某天做的某次量表”,评估明细表存“这次评估里每个条目的分”:

from sqlalchemy import Column, String, Integer, Float from sqlalchemy.orm import declarative_base Base = declarative_base() class Patient(Base): __tablename__ = "patients" patient_id = Column(String, primary_key=True) # 外部病历号不乱生成 name = Column(String, nullable=False) age = Column(Integer) diagnosis = Column(String) class AssessmentSession(Base): __tablename__ = "assessment_sessions" id = Column(Integer, primary_key=True, autoincrement=True) patient_id = Column(String, index=True) scale_id = Column(String) assessment_date = Column(String) # 存 ISO 格式 "2024-03-01" total = Column(Float) level = Column(String) class AssessmentDetail(Base): __tablename__ = "assessment_details" id = Column(Integer, primary_key=True, autoincrement=True) session_id = Column(Integer, index=True) item_id = Column(String) score = Column(Integer)

这里我故意把assessment_date存成字符串而不是日期类型,原因是 SQLite 的日期处理能力弱,跨库迁移到 MySQL 时 ISO 字符串也能直接用。只要统一存YYYY-MM-DD格式,字典序就是时间序。AssessmentDetail用长表而不是宽表,新加量表不需要改表结构,这是整套设计里最值得坚持的一个决定。

4. 数据集怎么用:从 CSV 读到康复趋势曲线

源码包里的数据集一般不是给你训练模型的,而是给你当“演示数据”和“校验样本”用的。常见是三份 CSV:患者档案、评估记录、关节采样数据。评估记录如果存的是长表格式,那刚好和数据库里的AssessmentDetail表对应上,可以无缝导入。但直接拿 CSV 画趋势图之前,有三个地方必须处理干净:编码、日期和字段合并。

4.1 用 pandas 读取患者档案与评估记录:字段语义与合并

第一步先把数据读进来。我这里统一用encoding="utf-8-sig",原因后面第 5 章会细说,现在先养成习惯:

import pandas as pd patients = pd.read_csv("data/patients.csv", encoding="utf-8-sig") records = pd.read_csv("data/assessment_records.csv", encoding="utf-8-sig") # patients: patient_id, name, age, sex, diagnosis, onset_date # records: patient_id, scale_id, assessment_date, item_id, score print(patients.head()) print(records.head())

这段代码的重点不在读取本身,而在理解 records 是一行长表:一行代表“某患者某次评估中某一条目的得分”。为什么要用长表?因为康复评估的量表条目数量不一样,FMA 有 50 项,Barthel 只有 10 项,长表不需要为了短量表补一堆空列。但画趋势图时,长表要按评估会话汇总成总分:

session_total = ( records.groupby(["patient_id", "scale_id", "assessment_date"]) .agg(total_score=("score", "sum")) .reset_index() ) print(session_total.sort_values("assessment_date").head())

这个groupby里的三个字段缺一不可。如果漏了scale_id,FMA 和 Barthel 的分数会混在一起求和,趋势线就没有意义;如果漏了assessment_date,同一天多次评估会被叠成一行,丢失随访粒度。

4.2 数据校验:评分越界、编码错误与日期排序

数据集不是官方发布的干净数据,来源多半是手工整理或历史 Excel,常出的问题有三个:日期是字符串、分数越界、同一个条目分数集合混乱。我用一段代码同时查这三类问题:

# 1) 日期统一成 datetime 格式,防止字符串排序出错 records["assessment_date"] = pd.to_datetime( records["assessment_date"], format="%Y-%m-%d" ) # 2) 按条目检查每个分值是否落在合法集合里 problem_items = [] for item_id, values in records.groupby("item_id")["score"].apply(set).items(): if values - {0, 1, 2}: # 以 FMA 为例,只允许 0/1/2 problem_items.append((item_id, sorted(values))) print("可疑条目:", problem_items)

这里values - {0, 1, 2}是集合差集,意思是“这个条目里出现了不在 0/1/2 范围内的分值”。如果数据集混入了一个 5 分,说明这个量表模板版本或分值集合定义有问题。实际项目里我会把第二个检查升级成模板驱动:从scale_templates里读出每个条目的allowed_scores,再和数据的实际分值集合比对,能反向揪出模板配置错误,效果比人工看强得多。

4.3 画一条恢复曲线:横轴用评估日期而不是训练次数

数据校验通过后,画趋势曲线是最直观的汇报方式。康复科的医生关心的是“患者从入院到现在功能恢复的轨迹”,横轴应该是评估日期,不是自然日期也不是训练次数,因为评估日期才是真正记录功能状态的时刻:

import matplotlib.pyplot as plt def plot_recovery(records, patient_id, scale_id="fma"): df = records[ (records["patient_id"] == patient_id) & (records["scale_id"] == scale_id) ].sort_values("assessment_date") total = df.groupby("assessment_date")["score"].sum() fig, ax = plt.subplots(figsize=(8, 4)) ax.plot(total.index, total.values, marker="o") ax.axhline(96, ls="--", color="green", lw=1, label="FMA 轻度障碍参考线 96") ax.set_title(f"{patient_id} 的 {scale_id} 康复趋势") ax.set_xlabel("评估日期") ax.set_ylabel("总分") ax.legend() return fig

画图代码里最该注意的是axhline(96)这条参考线。它让读者一眼看到患者有没有越过“轻度障碍”这条临床意义线,比光秃秃一条折线有用得多。另一个注意点是排序:sort_values("assessment_date")必须在groupby之前,否则折线会按原始数据顺序乱跳。

注意:同一患者同一天不能有两条同量表评估记录。如果数据集里有重复,趋势线上会出现竖直的跳变线。画图前先drop_duplicates按患者、量表、日期去重。

5. 康复评估系统避坑:5 条能省半周的踩坑记录

这个章节写的每一条,都是我在做康复类系统时真实遇到过、并且能在源码上直接定位到原因的坑。每条按“现象、原因、解决”来写,照着排查能省下不少时间。

5.1 量表版本混用:FMA 运动功能版和 126 分总表不是一回事

现象:报告里显示“上肢得分 60/66,下肢得分 25/34”,总分 85 被判定为“明显运动障碍”,但治疗师说这个患者恢复得还可以。

原因:FMA 有多个版本,网上流传的“完整版 FMA 量表”是 126 分总表,包含感觉、关节活动度和疼痛评估;而运动功能版只有 100 分。代码里的分级阈值套用了 126 分的标准,导致 100 分版的分数被低估。

解决:模板里加一个version字段,报告上直接展示“FMA 运动功能版(UE 33 项 + LE 17 项,总分 100)”。上线前找两三份真实评估记录做对照验证,确认阈值和总分来自同一个版本。

5.2 分值集合没校验:Barthel 洗澡项只有 0/5 分

现象:录入端把“洗澡”选成 10 分,系统算出总分 105,超过 100 后分级逻辑错乱。

原因:网上流传的 Barthel 表把“洗澡”写成 0/5/10,但原版只有 0/5。我没在模板里按条目限定分值集合,只设了个总分上限。

解决:模板定义阶段给每个条目写清楚allowed_scores,前端生成选项、后端校验都依赖这份配置。非法分值直接拒绝,不要让系统“宽容”地算下去。

5.3 从 Excel 导出的 CSV 列名带着 \ufeff

现象:pd.read_csv("patients.csv")之后打印列名,第一列显示\ufeffpatient_id,和数据库字段patient_idmerge 时一直 KeyError。

原因:Excel 的“另存为 CSV(UTF-8)”带 BOM 头,pandas 按无 BOM 解析时把\ufeff并进第一个列名。

解决:所有手工来源的 CSV 统一用encoding="utf-8-sig"读取,写回时加index=False避免行号列混进去。这个习惯要从第一天就养成,不要等数据洗了三遍才发现。

5.4 同一患者的多条记录同时进训练集和验证集

现象:用评估记录训练模型预测“下次能否达到轻度障碍”,交叉验证 AUC 有 0.9,部署后掉到 0.7。

原因:康复随访数据天然按患者聚集,同一个人的多次评估高度相关。随机切分把同一个患者的记录同时分进训练集和验证集,模型实际上在“记忆这个人”而不是学习康复规律。

解决:验证集必须按patient_id分组,用GroupKFold或GroupShuffleSplit切分,并且保证同一天的多次记录不能被分到两个 fold 里。做完切分后检查一下每个 fold 里的患者 ID 是否有重叠,这一步不能省。

5.5 前端页面直接调 API 被 CORS 拦住

现象:浏览器打开static/index.html,fetchhttp://localhost:8000/api/assessments时控制台报 “has been blocked by CORS policy”。

原因:FastAPI 默认不添加CORSMiddleware,浏览器不允许跨源请求。前端文件从本地文件系统打开时,源跟 API 服务不是一个源。

解决:入口文件里加上app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"])。开发期放开没问题,生产部署时把*换成科室内网的实际地址。

6. 进阶技巧:把关节活动度(ROM)接进评估结论

如果这个源码包只有量表评估,它的价值到上一章就结束了。但康复评估真正有区分度的地方,是把“主观量表”和“客观运动数据”结合起来。我这里教一个能立刻接进去的技巧:从关节角度采样数据里计算活动度(ROM),并输出一句评估结论。

假设data/joint_samples.csv里有这样的字段:patient_id、timestamp、motion(flex/ext)、angle_deg。要算一次肩关节前屈的活动范围,代码可以这样写:

import pandas as pd from scipy.signal import savgol_filter df = pd.read_csv("data/joint_samples.csv", encoding="utf-8-sig") subject = df[df["patient_id"] == "P001"].sort_values("timestamp") # 只取屈曲动作段,过度到伸展段会引入无效区间 flex = subject[subject["motion"] == "flex"].copy() # 滤波去抖动,直接用 max-min 会把动作起止时的抖动量算进去 angle = savgol_filter(flex["angle_deg"], window_length=11, polyorder=2) rom = angle.max() - angle.min() print(f"肩关节前屈 ROM: {rom:.1f}°")

这里savgol_filter的window_length=11是经验值,适合 50Hz 左右的采样率;采样率高到 100Hz 时可以调到 15。如果源码包没带 scipy,用移动平均也可以,但 Savitzky-Golay 滤波对峰值保持更友好。ROM 算出来之后,可以按参考范围出结论,比如肩前屈正常参考是 180° 左右:

def rom_level(rom): if rom >= 160: return "基本正常" if rom >= 120: return "轻度受限" if rom >= 60: return "中度受限" return "显著受限"

这个分级只是示例,实际阈值要按被测关节和患者人群调。接入系统时,rom()作为services/rom.py里的一个函数,评估报告里同时显示“FMA 得分 + 肩前屈 ROM”,比单独一个量表分数更能说明问题。

验证这个方法是否可靠,我习惯造两组模拟数据自测:一组是健康的 30° 到 180° 屈曲,一组是受限的 30° 到 90° 屈曲,喂给同一个compute_rom(),确认健康组被判“基本正常”、受限组被判“显著受限”。如果连这两条理想曲线都分不出来,那真实数据里的噪声只会更惨。

这几年做康复类系统,我最深的教训是:康复评估的难点不在界面,也不在算法,而在“让治疗师认可你算出来的数”。每一个分数、每一条参考线,都要能追溯到量表原文或科室共识,否则做出来的系统只会躺在 U 盘里。把模板配置化、把校验前置、把数据集里的日期和编码盯死,这套最小系统就能成为治疗师每天真正在用的工具。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进