简介:这是一份基于Python、Flask与ECharts构建的城市空气质量数据分析及可视化项目,适合Python期末大作业或数据可视化入门学习。项目包含空气质量预报与天气数据可视化两大模块,覆盖全国375个城市实时AQI指数、首要污染物及最高/最低气温、紫外线、湿度、能见度、风速等时序变化,并通过地图、表格、散点图、折线图、饼图实现多维度交互展示。资源包共10个文件,以Python脚本、CSV数据文件、HTML模板、演示录屏和说明文档为主,总体积4.83MB。其中3个py文件分别实现数据抓取、预测与Flask视图,3个csv提供城市列表与空气质量历史数据,webm录屏直观展示页面跳转与交互效果。代码结构清晰,包含完整源码和可直接运行的数据文件,便于二次开发或改造为课程设计。目前已有1148人学习浏览,适合具备基础Python语法、希望快速掌握Flask+ECharts集成开发的学习者。
1. 城市空气质量大作业:Flask + ECharts 的完整闭环
期末拿到一个“城市空气质量数据分析及可视化”的 Python 大作业,最常见的翻车方式是:花了一周调图表样式,最后却发现数据管道是断的——要么 CSV 里全是脏数据,要么 Flask 接口返回不了 JSON,要么 ECharts 拿到了数据却渲染不出来。这份资源的价值在于它不是零散的代码片段,而是从数据文件、清洗脚本、Flask 后端到 ECharts 前端的一条完整链路,能直接跑起来当大作业交,也能拆开当脚手架用。适合刚学完 Python 基础、正被 Flask 和 ECharts 联动折磨的本科学生,也适合想快速搭一个数据可视化大屏原型的从业者。先说明:页面好看是结果,数据管道扎实才是核心。
2. 先理数据再写网页:从 CSV 到 DataFrame 的清洗管道
2.1 数据字段怎么定:AQI、六项污染物与时间粒度
空气质量数据集的字段设计直接决定了后面图表能不能画出来。项目数据文件里的字段一般包含:城市名称、监测时间、AQI 指数,以及六项污染物浓度(PM2.5、PM10、SO2、NO2、CO、O3)。其中 AQI 是综合指数,用于展示总体趋势;六项污染物用于雷达图、饼图和对比柱状图。
时间粒度是一个关键取舍。有的数据源是按小时记录的,有的按天记录,这份资源里的数据文件既有小时粒度也有日粒度,处理时我用 pandas 做降采样(resample)把小时数据聚合成日均值。这里要注意:resample 之后索引会变成 DatetimeIndex,如果直接存回 CSV 再读出来,日期类型会变成字符串,接口层处理时要重新解析。
字段映射建议做成一个常量字典,方便后面接口层引用,也方便老师查代码时一眼看懂字段含义:
FIELD_MAP = { "city": "城市", "time": "监测时间", "aqi": "AQI指数", "pm25": "PM2.5浓度", "pm10": "PM10浓度", "so2": "SO2浓度", "no2": "NO2浓度", "co": "CO浓度", "o3": "O3浓度" }这段映射表把英文字段名和中文表头对应起来,核心作用是让你在清洗、接口、前端三个阶段使用同一套命名,不至于在 Flask 返回 JSON 时突然冒出中文 key,导致前端取不到数据。我一般还会在末尾加一行assert len(FIELD_MAP) == 9,防止后续加字段时漏改。
2.2 pandas 清洗的几个关键动作
原始数据文件不会是干净的,我拆这份资源时发现典型的脏数据有三种:空值(NaN)、字符串混入数值列、监测时间格式不统一。清洗时先读文件再逐列处理,每一步操作都要有输出检查。
import pandas as pd df = pd.read_csv("data/air_quality.csv", encoding="utf-8") df.columns = [FIELD_MAP.get(col, col) for col in df.columns] df["监测时间"] = pd.to_datetime(df["监测时间"], errors="coerce") df = df.dropna(subset=["监测时间", "AQI指数"]) num_cols = ["PM2.5浓度", "PM10浓度", "SO2浓度", "NO2浓度", "CO浓度", "O3浓度"] for col in num_cols: df[col] = pd.to_numeric(df[col], errors="coerce") df = df[(df["AQI指数"] >= 0) & (df["AQI指数"] <= 500)] df = df.sort_values("监测时间").reset_index(drop=True) df.to_csv("data/air_quality_clean.csv", index=False, encoding="utf-8-sig")这段清洗里比较容易被忽略的是errors="coerce"参数。pd.to_datetime遇到无法解析的日期会变成 NaT,pd.to_numeric遇到非数值字符串会变成 NaN,这样才不会中断主流程,但后续dropna会把这些行剔除。我用utf-8-sig写回 CSV,是为了让 Excel 打开时中文不乱码,实际给 Flask 读取时用普通utf-8就行。
AQI 指数范围我做了 0 到 500 的过滤,这是国标范围。如果数据里有超过 500 的值,说明源数据可能混入了仪器异常记录,直接剔除比留着更安全。清洗完成后最好打印一下df.info(),确认没有空值和异常类型再继续。
2.3 存成接口层要用的结构
清洗完的数据不能直接给 Flask 用,因为接口层需要的是“按城市聚合”“按时间聚合”的统计结果,而不是原始明细表。常见的做法是提前算好几张宽表:按城市聚合的均值表、按日期聚合的 AQI 趋势表、六项污染物的平均浓度表。
city_avg = df.groupby("城市")["AQI指数"].mean().sort_values() trend = df.groupby(df["监测时间"].dt.date)["AQI指数"].mean() pollutant_avg = df[num_cols].mean().sort_values(ascending=False)这三行是后续所有图表的数据基础。注意groupby之后得到的是 Series 而不是 DataFrame,接口层序列化时要转回 Python 原生类型。城市聚合结果用于柱状图排名;时间趋势用于折线图;污染物均值用于饼图或雷达图。我习惯把这三张结果表在启动 Flask 时一次性加载进模块级变量,而不是每次请求都重新聚合,因为数据文件不大,重复计算纯属浪费。这一步做好了,后端接口会简单到只剩取值和返回。
3. Flask 后端:把查询结果变成 JSON 接口
3.1 路由设计:一个大屏页面 + 四个数据接口
这个项目的后端职责非常明确:渲染一个首页模板,然后提供四个 JSON 接口给前端图表用。用 Flask 做这件事比 FastAPI 顺手的地方在于——Flask 内建 Jinja2 模板渲染,页面接口和数据接口可以在同一个进程里跑,不用额外配静态文件服务,对大作业体量的项目来说是零学习成本。FastAPI 的异步性能和自动文档很强,但这里没有用武之地,杀鸡不用牛刀。
接口设计遵循“一次请求专供一个图表”的原则,这样前端每个图表只关心自己的数据源,单独调试时也能在浏览器直接访问接口看 JSON 结构。
from flask import Flask, jsonify, render_template app = Flask(__name__) @app.route("/") def index(): return render_template("index.html") @app.route("/api/city_rank") def city_rank(): data = {"categories": city_avg.index.tolist(), "values": city_avg.round(1).tolist()} return jsonify(data) @app.route("/api/aqi_trend") def aqi_trend(): data = {"dates": trend.index.astype(str).tolist(), "values": trend.round(1).tolist()} return jsonify(data)/api/city_rank返回城市排名,/api/aqi_trend返回时间趋势。这里有一个细节:city_avg.index是 pandas Index 对象,tolist()之后会转成 Python 列表,但round(1)返回的仍是 Series,必须再套一次tolist()。很多初学翻车就翻在少了一层转换,Flask 的jsonify底层的 JSON 编码器不认识 pandas 的 Series 和 numpy 类型。
3.2 从 DataFrame 到 JSON:序列化与日期处理
最容易被坑的是日期序列化。trend.index是date对象,直接jsonify会报Object of type date is not JSON serializable。常见做法是在接口内部统一做字符串转换,而不是去改 Flask 全局的 JSON 编码器——全局改动影响范围太大,排查问题时不方便。
@app.route("/api/pollutant_pie") def pollutant_pie(): data = [ {"name": name, "value": float(pollutant_avg[name])} for name in pollutant_avg.index ] return jsonify(data)这里用float()把 numpy.float64 强转成 Python float,是防TypeError: Object of type float64 is not JSON serializable的标准动作。另一个隐藏细节是pollutant_avg已经排过序,所以饼图数据顺序天然是从高到低。如果你想让前端自己排序,也可以不排序直接返回,但我一般倾向于后端把数据处理完,前端只负责渲染——这样前端代码可以短很多,老师检查代码时也更直观。
3.3 模板与静态资源的组织方式
Flask 项目目录如果不规划清楚,接口写好也会乱成一团。推荐结构是:app.py放路由,templates/放 HTML,static/css/放样式,static/js/放页面脚本和数据请求。数据文件放data/目录,清洗脚本独立成preprocess.py,这样大作业答辩时能说清楚“数据处理和后端分离”的设计思路。
air-quality-project/ ├── app.py # Flask 主程序,含所有路由 ├── preprocess.py # 数据清洗脚本 ├── requirements.txt # 依赖清单 ├── data/ │ ├── air_quality.csv # 原始数据 │ └── air_quality_clean.csv # 清洗后数据 ├── templates/ │ └── index.html # 大屏页面 └── static/ ├── css/style.css └── js/main.js # ECharts 相关逻辑这个目录结构是 Flask 官方推荐的默认约定,templates和static这两个目录名是框架写死的。把数据文件和代码分开的最大好处是:提交大作业时可以直接说明数据来源和清洗流程,不用在代码里翻半天找数据在哪。requirements.txt里我会固定 pandas、Flask 两个核心依赖,版本号用>=而不是==,避免换机器装不上。
4. ECharts 前端:把 JSON 变成大屏图表
4.1 图表选型:折线、柱状、饼图、雷达分到哪个位置
大屏页面不是几个图表的简单堆叠,每个图表要对应一个分析问题。我在这份资源里看到的图表布局逻辑是这样的:左侧放城市 AQI 排名(柱状图),中间主区域放 AQI 时间趋势(折线图),右侧放六项污染物占比(饼图),下方或侧边再放六项污染物的雷达图。柱状图适合排名对比,折线图适合趋势观察,饼图适合占比结构,雷达图适合多维指标对比——选型逻辑要能在答辩时讲出来。
ECharts 的图表初始化都是同一个套路:echarts.init(dom)+setOption(option)。以柱状图为例:
const chartDom = document.getElementById('rankChart'); const rankChart = echarts.init(chartDom); fetch('/api/city_rank') .then(res => res.json()) .then(data => { rankChart.setOption({ title: { text: '城市 AQI 年均值排名' }, tooltip: {}, grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }, xAxis: { type: 'category', data: data.categories }, yAxis: { type: 'value', name: 'AQI' }, series: [{ type: 'bar', data: data.values, itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: '#83bff6' }, { offset: 1, color: '#2f8cf7' } ]) } }] }); });这段代码里fetch返回的是 Promise,所以setOption必须在.then()里面调用,这是一个时序问题——如果写在fetch外面,图表会拿到空数据然后一直空白。柱状图我加了渐变色LinearGradient,这是让大屏看起来不廉价的常用技巧,成本只有几行配置。grid.containLabel: true能防止左侧城市名被截断,属于画完必调的参数。
4.2 请求与渲染时序:Ajax 拿到数据再 setOption
饼图、折线图、雷达图的写法都遵循同一套模式。但有一个高频翻车点:多个图表同时发请求时,后端的响应速度不同,页面会出现“图表一个一个蹦出来”的效果。我一般会在页面加载时统一用一个initAllCharts()函数管理,并且在每个图表初始化前先清空 DOM 宽度。
async function initAllCharts() { const [rankRes, trendRes, pieRes, radarRes] = await Promise.all([ fetch('/api/city_rank').then(r => r.json()), fetch('/api/aqi_trend').then(r => r.json()), fetch('/api/pollutant_pie').then(r => r.json()), fetch('/api/radar').then(r => r.json()) ]); renderRank(rankRes); renderTrend(trendRes); renderPie(pieRes); renderRadar(radarRes); }用Promise.all并发请求,四个接口的等待时间是最大值而不是总和,页面整体加载更快。前端渲染函数各自独立,接口出错时也只在控制台看到单个请求失败,不会整个页面白屏。注意每个渲染函数内部都要做if (data.categories.length === 0) return;的空数据保护,否则后端返回空列表时 ECharts 会报There is no chart to display。
4.3 与 Flask 联调时的前后端约定
前后端联调最容易出问题的不是代码逻辑,而是字段名对不上。我见过太多案例:后端返回categories,前端写成了category,调试半小时才发现多了一个 s。解决办法是在项目初期就定好接口返回格式的统一约定:列表字段统一叫categories和values,对象数组统一用{ name, value }结构。
饼图用的是{ name, value }对象数组,柱状图和折线图用{ categories, values }两个平行数组,雷达图用{ indicators, values }。这样约定之后,前端每个图表的渲染函数只需要关心三种数据结构,不用为每个接口单独写解析逻辑。另一个联调技巧是直接在浏览器地址栏访问/api/city_rank,看返回的 JSON 是不是预期的字段和类型,这一步能把绝大多数前后端问题定位到具体层。
5. 运行与部署避坑:从本地起服务到局域网可见
5.1 环境依赖与版本问题
先把环境跑通再谈功能。我拆资源时会先建一个干净的虚拟环境,避免和系统 Python 混淆包版本。pandas 2.x 和 Flask 3.x 之间没有兼容性问题,但如果机器上装的是 pandas 1.x,groupby.mean()和resample的 API 基本一致,不用担心。容易踩的坑是 numpy 版本过老导致pd.to_numeric在特定字符串格式下解析失败——解决方法就是升级 numpy,不要改代码去兼容。
python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install flask pandas python preprocess.py python app.pypreprocess.py先执行可以确认数据清洗没问题,再启动 Flask。如果跳过清洗直接跑app.py,接口返回的数据很可能全是 NaN 或者空列表,到时候排查起来要同时怀疑数据和代码两个环节,非常被动。我一般会先看清洗脚本的输出目录里有没有生成air_quality_clean.csv,再决定要不要进下一步。
5.2 坑 1:图表区域空白,控制台报 “There is no chart to display”
现象:页面能打开、接口能返回 JSON,但 ECharts 图表区域一片空白。原因:最常见的有两个,一个是 ECharts 的 JS 文件没加载成功(CDN 被墙或网络慢),另一个是放置图表的 div 没有设置高度。ECharts 初始化时如果容器高度为 0,图表会静默失败,不报错但也不渲染。解决:先把 ECharts 换成本地静态文件引入,再给每个图表容器显式设置style="height: 400px"。
index.html里如果引入的是 CDN 地址,要把<script src="https://cdn.../echarts.min.js"></script>下载到本地static/js/目录,然后改成相对路径引用。这样答辩时即使现场网络不好,页面也能正常展示。给 div 设最小高度是前端基本功,这里单独提是因为 ECharts 对容器高度异常敏感,它的 canvas 会直接继承一个 0 高度。
5.3 坑 2:Flask 报 “Object of type int64 is not JSON serializable”
现象:浏览器访问接口直接 500,终端里报这个错。原因:pandas 计算出的均值、聚合结果在转 JSON 时,元素类型是 numpy.int64 或 numpy.float64,Flask 自带的 JSON 编码器不认这些类型。解决:在接口返回值前统一做类型转换,int()、float()、str()三件套。
# 错误的做法 return jsonify({"value": city_avg[0]}) # 正确的做法 return jsonify({"value": float(city_avg[0])})这个坑我只遇到一次就记住了,因为报错信息特别直白。更隐蔽的情况是city_avg.index.tolist()返回的城市名是 numpy.str_ 类型,同样会触发序列化错误。所以我现在写接口的习惯是:所有从 pandas 出来的值,一律过一遍原生类型转换,不抱侥幸心理。
5.4 坑 3:host=0.0.0.0起了服务,但室友访问不了
现象:本地127.0.0.1:5000能访问,局域网内另一台电脑输入192.168.x.x:5000打不开。原因:Windows 防火墙拦住了 Python 进程的入站请求,或者两台设备不在同一个网段。解决:先app.run(host="0.0.0.0", port=5000, debug=True)确认服务监听所有网卡,然后检查防火墙入站规则。
Windows 下第一次运行时会弹出防火墙授权窗口,如果不小心点了取消,之后就得手动去“高级安全 Windows Defender 防火墙”里放行 Python。macOS 和 Linux 一般没有这个问题。另外检查局域网连通最直接的办法是在另一台机器上ping 192.168.x.x,ping 通再怀疑端口问题,ping 不通就先查网段和防火墙。
5.5 坑 4:数据读出来中文乱码
现象:Flask 接口返回的中文城市名变成乱码,或者前端显示“寮哄害”之类的乱码字符。原因:CSV 文件编码不一致,read_csv用了错误编码,或者响应头缺少charset=utf-8。解决:读取时强制指定编码,df = pd.read_csv("...", encoding="utf-8"),如果源文件是 GBK 就改encoding="gbk";Flask 侧用jsonify已经自动处理了 UTF-8,所以主要检查数据源。
# 读取时尝试两种编码,自动降级 try: df = pd.read_csv("data/air_quality.csv", encoding="utf-8") except UnicodeDecodeError: df = pd.read_csv("data/air_quality.csv", encoding="gbk")这段try/except是我处理来源不明数据文件时的标准做法。很多公开数据集是 GBK 编码,在 Windows 上导出 CSV 尤其常见。如果两种编码都失败,就用errors="ignore"先看一下哪里断了,避免直接让整个程序崩溃。
6. 扩建与加分技巧:把大作业做出彩
6.1 加一张累计均值卡片
大作业要拿高分,光有四个图表还不够,加一个数据总览卡片能明显提升完整度。卡片显示“全部城市平均 AQI”“最干净城市”“首要污染物”三块信息,前端用一个fetch接口拉回来填进 HTML。
@app.route("/api/overview") def overview(): clean_city = city_avg.idxmin() data = { "avg_aqi": float(city_avg.mean().round(1)), "best_city": str(clean_city), "best_aqi": float(city_avg.min().round(1)) } return jsonify(data)idxmin()能直接拿到均值最小的城市名,这一步比先排序再取第一个更简洁。前端拿到数据后往三个<span>里填文本,不需要任何图表库。这张卡片放在页面顶部,老师打开第一眼就能看到分析结论,比把结论藏在图表里更直观。
6.2 用 Jinja2 变量动态切换城市
如果项目要求分析多个城市,而不是只看整体,可以用 Jinja2 模板变量实现城市切换。在 Flask 路由里接收查询参数,再传给模板:
@app.route("/") def index(): selected = request.args.get("city", "北京") return render_template("index.html", selected_city=selected)index.html里用{{ selected_city }}渲染到页面标题,前端 JS 在初始化时读这个值拼进接口查询参数。但要注意:接口路由也要支持city参数,否则前端切换城市后图表数据不会变。这个功能单独做有点繁琐,我一般是改一个接口支持城市过滤,其他接口保持全量数据,减少重复工作量。
6.3 验收检查清单
答辩前我会按这张清单过一遍,几乎能堵住所有翻车点:
| 检查项 | 操作 | 预期结果 |
|---|---|---|
| 数据清洗 | 运行preprocess.py | 生成air_quality_clean.csv,无报错 |
| 接口自测 | 浏览器访问/api/city_rank | 返回合法 JSON,中文正常 |
| 页面加载 | 刷新http://127.0.0.1:5000/ | 四个图表全部渲染,无空白 |
| 边界处理 | 访问/api/aqi_trend | 日期按升序排列,无空值 |
| 局域网访问 | 手机或另一台电脑访问 | 能打开页面,图表可交互 |
这张表其实是我自己的“后悔药”机制,因为以前吃过亏:答辩当天突然换了一台电脑,环境没配好,页面白屏只能尬聊。从那以后我每次在交付或者展示前,都会强制走一遍这五步验收,确认环境和代码都没问题才敢松一口气。至于那些序列化、CDN、防火墙的坑,多踩几次就长记性了,网上搜来的经验总不如自己亲手修一遍来得实在。希望这份拆解能帮你少走一段弯路,大作业顺利过关。
本文还有配套的精品资源,点击获取