玫瑰花园管理系统:AI识病+3D可视化,一套面向中小型玫瑰种植园的数字化管理工具
背景
中小型玫瑰种植园(10-100亩)普遍面临几个问题:
病害发现不及时。工人巡园主要靠经验,新手容易漏诊误诊,等发现严重了已经传染一片。
园区状态靠纸质记录。老板想知道"哪个地块最近有病害"需要翻记录本,没有全局视图。
汇报材料靠手写。申请补贴、做经营汇报时,没有数据支撑。
这套系统的目标很直接:工人手机拍照,AI告诉你是什么病、怎么治;管理者打开网页,整个园区的健康状况一目了然。




技术栈
| 层 | 技术 |
|---|---|
| 后端 | FastAPI + Python 3.11+ |
| 数据库 | SQLite + SQLAlchemy 2.0 (异步) |
| 前端 | Vue 3 + Vite |
| 3D/图表 | Three.js + ECharts |
| AI视觉 | 阿里云百炼 Qwen-VL |
| 通信 | WebSocket(实时推送) |
| 认证 | JWT |
一、数据模型设计
系统共 7 张表,覆盖园区管理的核心实体和关系。
地块 (Plot)
python
class Plot(Base):
__tablename__ = "plots"
id = Column(Integer, primary_key=True)
name = Column(String(50), nullable=False)
area = Column(Float, nullable=False)
variety_id = Column(Integer, ForeignKey("varieties.id"))
planted_at = Column(Date)
soil_type = Column(String(20))
status = Column(String(20), default="healthy") # healthy/warning/sick/severe
boundary = Column(Text) # JSON 多边形坐标
notes = Column(Text)
deleted_at = Column(DateTime) # 软删除
variety = relationship("Variety", lazy="joined")
sick_records = relationship("SickRecord", back_populates="plot")
每个地块关联一个品种,通过 sick_records 反向查询所有识病记录。status 字段不直接写入,而是通过 _calc_status() 方法动态计算。
识病记录 (SickRecord)
python
class SickRecord(Base):
__tablename__ = "sick_records"
id = Column(Integer, primary_key=True)
plot_id = Column(Integer, ForeignKey("plots.id"), nullable=False)
image_url = Column(String(255), nullable=False)
disease_name = Column(String(100), nullable=False)
confidence = Column(Float, nullable=False)
severity = Column(String(20), nullable=False) # mild/moderate/severe
suggestion = Column(Text, nullable=False)
worker_id = Column(Integer, ForeignKey("users.id"))
status = Column(String(20), default="pending") # pending/treated
treatment_method = Column(String(50))
treatment_note = Column(Text)
treated_image_url = Column(String(255))
treated_at = Column(DateTime)
created_at = Column(DateTime, server_default=func.now())
plot = relationship("Plot", back_populates="sick_records")
worker = relationship("User")
这是一个处理闭环模型:从发现 (pending) 到处理完成 (treated),全链路可追溯。treated_image_url 支持处理后拍照复查。
其他表
User:用户表,支持多角色(admin/manager/worker),带微信 UnionID 字段预留小程序接入。
Variety:品种档案,含名称、花色、种植密度、抗病评分、图片URL。
Task:任务表,分自动生成(浇水/施肥/打药周期任务)和手工创建。含优先级、截止时间、完成状态。
PlotWorker:地块和工人的多对多关联。
OperationLog:操作审计日志,记录每次关键操作的时间、用户和内容。
二、AI 模型池自动切换
这是项目的一个技术亮点。阿里云百炼有几十个视觉模型,各自的免费额度和可用性不断变化。手动维护不现实,所以用了一个模型池 + 自动重试机制。
模型池定义
python
# model_pool.py
# 按优先级从上到下排列,失败自动切下一个
VISION_MODELS = [
"qwen3.5-plus-2026-04-20", # 主力,有免费额度
"qwen3.7-plus", # 新产品备用
"qwen3.7-plus-2026-05-26",
"qwen3.7-max-2026-06-08", # 旗舰
"qwen3.7-max",
"qwen3.7-max-preview",
"qwen3.7-max-2026-05-17",
"qwen3.7-max-2026-05-20",
"qwen3.6-plus",
"qwen3.6-27b",
]
TEXT_POOL = [
"qwen3.7-max",
"qwen3.7-plus",
"deepseek-v4-flash",
"deepseek-v4-pro",
"qwen3.5-plus-2026-04-20",
"glm-5.2",
"kimi-k2.7-code",
"qwen3.6-plus",
"qwen3.6-27b",
]
自动切换实现
python
# qwen_adapter.py 核心逻辑
async def identify_disease(image_bytes: bytes) -> dict:
img_b64 = base64.b64encode(image_bytes).decode()
# 遍历模型池,第一个成功的返回
for model in VISION_MODELS:
try:
client = OpenAI(
api_key=settings.QWEN_API_KEY,
base_url=settings.QWEN_BASE_URL,
)
response = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": [
{"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{img_b64}"
}},
{"type": "text",
"text": "请识别这张玫瑰叶片的病害情况"},
],
},
],
timeout=60
)
text = response.choices[0].message.content
result = _parse_json(text)
result["_model"] = model
return result
except Exception as e:
continue # 失败就切下一个
raise Exception("所有视觉模型均不可用")
对应的 System Prompt:
python
SYSTEM_PROMPT = """你是一个专业的玫瑰病害识别专家。
请分析这张图片中的玫瑰叶片,判断是否存在病害或虫害。
如果存在,请给出:
1. 病害名称(中文)
2. 置信度(百分比,整数)
3. 严重程度(轻微/中等/严重)
4. 防治建议(包括药剂名称、用法用量)
请直接输出JSON,不要使用markdown格式。
格式: {"disease_name":"...","confidence":80,"severity":"轻微","suggestion":"..."}
如果无法识别,返回: {"disease_name":"无法识别","confidence":0,"severity":"轻微","suggestion":"请重新拍摄清晰的照片"}"""
模型返回的结果是纯文本,需要双重解析:
python
def _parse_json(text: str) -> dict:
text = text.strip()
# 尝试直接解析
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# 尝试从 markdown 代码块提取
match = re.search(r'```(?:json)?\s*([\s\S]*?)```', text)
if match:
try:
return json.loads(match.group(1).strip())
except json.JSONDecodeError:
pass
# 最后尝试提取大括号内容
match = re.search(r'\{[\s\S]*\}', text)
if match:
try:
return json.loads(match.group(0))
except json.JSONDecodeError:
pass
return {"disease_name": "解析失败", "confidence": 0,
"severity": "mild", "suggestion": text}
三、拍照识病完整流程
从用户手机拍照到展示诊断结果,共 6 步:
python
class IdentifyService:
def __init__(self, db: AsyncSession, user_id: int):
self.repo = SickRecordRepository(db)
self.log_repo = OperationLogRepository(db)
self.user_id = user_id
async def identify(self, image_bytes: bytes,
plot_id: int, notes: str = None):
# 1. 保存图片到 uploads/sick_records/
file_path = await save_upload(image_bytes, "sick_records")
# 2. 调用 AI 识别(带自动降级)
try:
ai_result = await identify_disease(image_bytes)
disease_name = ai_result.get("disease_name", "无法识别")
confidence = ai_result.get("confidence", 0)
severity = ai_result.get("severity", "mild")
suggestion = ai_result.get("suggestion", "暂无建议")
except Exception:
# API 不可用时降级为模拟数据
disease_name = "无法识别"
confidence = 0
severity = "mild"
suggestion = "AI识别服务暂不可用,请稍后再试"
# 3. 保存识病记录到数据库
record_data = {
"plot_id": plot_id,
"image_url": file_path,
"disease_name": disease_name,
"confidence": confidence,
"severity": severity,
"suggestion": suggestion,
"worker_id": self.user_id,
"status": "pending",
}
record_data = {k: v for k, v in record_data.items()
if v is not None}
record = await self.repo.create(record_data)
# 4. 严重病害 WebSocket 推送告警
if severity == "severe":
await ws_manager.push_alert({
"plot_id": plot_id,
"disease": disease_name,
"severity": severity,
"time": record.created_at.isoformat()
})
# 5. 记录操作日志
await self.log_repo.create({
"user_id": self.user_id,
"action": "拍照识病",
"detail": f"地块#{plot_id}识别到{disease_name}"
})
# 6. 返回结果给前端
return {
"id": record.id,
"disease_name": disease_name,
"confidence": confidence,
"severity": severity,
"suggestion": suggestion,
"image_url": file_path,
"_model": ai_result.get("_model", ""),
"created_at": record.created_at.isoformat()
}
图片存储采用本地文件系统,文件名用 UUID 避免冲突:
python
async def save_upload(file_bytes: bytes, subdir: str) -> str:
upload_dir = settings.UPLOAD_DIR / subdir
upload_dir.mkdir(parents=True, exist_ok=True)
filename = f"{uuid4().hex}.jpg"
path = upload_dir / filename
async with aiofiles.open(path, "wb") as f:
await f.write(file_bytes)
return f"/uploads/{subdir}/{filename}"
四、WebSocket 实时推送
大屏需要实时刷新数据,用 WebSocket 代替轮询。
python
class WebSocketManager:
def __init__(self):
self.connections: list[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.connections.append(websocket)
def disconnect(self, websocket: WebSocket):
if websocket in self.connections:
self.connections.remove(websocket)
async def broadcast(self, event: str, data: dict):
dead = []
for conn in self.connections:
try:
await conn.send_json({"event": event, "data": data})
except Exception:
dead.append(conn)
for conn in dead:
self.disconnect(conn)
async def push_alert(self, alert: dict):
await self.broadcast("new_alert", alert)
async def push_dashboard_update(self, data: dict):
await self.broadcast("dashboard_update", data)
FastAPI 注册 WebSocket 路由:
python
router = APIRouter()
@router.websocket("/ws/dashboard")
async def dashboard_ws(websocket: WebSocket):
await ws_manager.connect(websocket)
try:
while True:
await websocket.receive_text() # 保持连接
except WebSocketDisconnect:
ws_manager.disconnect(websocket)
前端连接:
javascript
// stores/dashboard.js
const ws = new WebSocket(`ws://${host}/ws/dashboard`)
ws.onmessage = (event) => {
const { event: type, data } = JSON.parse(event.data)
if (type === 'new_alert') {
alerts.unshift(data)
} else if (type === 'dashboard_update') {
updateIndicators(data)
}
}
五、地块状态自动计算
地块状态不直接存储在数据库,而是通过最近 7 天的识病记录动态计算:
python
async def _calc_status(self, plot: Plot) -> str:
"""根据最新识病记录自动计算地块状态"""
if not plot.sick_records:
return "healthy"
# 取最近 7 天内有记录的
recent = [
r for r in plot.sick_records
if r.created_at and
r.created_at >= (datetime.now() - timedelta(days=7))
]
if not recent:
return "healthy"
# 按严重程度决定状态
latest = max(recent, key=lambda r: r.created_at)
severity_map = {
"severe": "severe",
"中等": "sick",
"中等": "warning",
"mild": "healthy",
"轻微": "healthy",
}
return severity_map.get(latest.severity, "warning")
这样实现了"地块健康状态自动刷新"------工人今天拍了照诊断出病害,地块状态立刻从 green 变成 yellow/red。
六、前端核心组件
拍照识病页面(双栏布局)
页面分左右两栏:左侧选地块 + 拍照上传,右侧展示 AI 诊断结果。
vue
<template>
<AppLayout>
<div class="identify-layout">
<div class="left-panel">
<h2>拍照识病</h2>
<div class="form-row">
<label>地块</label>
<select v-model="plotId">
<option value="">-- 请选择 --</option>
<option v-for="p in plots" :key="p.id" :value="p.id">
{{ p.name }} · {{ p.variety_name || '未种' }}
</option>
</select>
</div>
<div class="upload-box" @click="triggerUpload">
<input ref="fileInput" type="file" accept="image/*"
capture="environment" style="display:none"
@change="onFileChange" />
<div v-if="!preview" class="upload-placeholder">
<span class="icon">📷</span>
<span>点击拍照或选择图片</span>
</div>
<img v-else :src="preview" class="preview-img" />
</div>
<button class="btn-primary"
:disabled="!plotId || !preview || loading"
@click="handleIdentify">
{{ loading ? "识别中..." : "开始识别" }}
</button>
</div>
<div class="right-panel">
<h3>诊断结果</h3>
<div v-if="!result && !loading" class="empty-result">
<span>拍照识别后将展示诊断结果</span>
</div>
<div v-if="loading" class="loading-result">
<div class="spinner"></div>
<span>AI 正在分析图片...</span>
</div>
<div v-if="result" class="result-card">
<div class="result-header">
<span class="result-name">{{ result.disease_name }}</span>
<span class="badge" :class="result.severity">
{{ severityLabels[result.severity] }}
</span>
</div>
<div class="result-section">
<div class="section-label">置信度</div>
<div class="bar-track">
<div class="bar-fill"
:style="{ width: result.confidence + '%' }"></div>
</div>
<div class="bar-text">{{ result.confidence }}%</div>
</div>
<div class="result-section">
<div class="section-label">防治建议</div>
<div class="suggestion-text">{{ result.suggestion }}</div>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
七、报告自动生成
支持一键生成周报/月报,包含:封面、目录、园区概况、病害统计、任务完成率、品种分布、下期计划。
报告内容由 AI 文本模型自动撰写,写入 HTML 模板后返回给前端。
python
async def generate_report_html(period: str, db: AsyncSession) -> str:
# 1. 查询统计数据
plot_repo = PlotRepository(db)
record_repo = SickRecordRepository(db)
plots = await plot_repo.find_all()
records, _ = await record_repo.find_all(start_date=start, end_date=end)
# 2. 计算指标
total_plots = len(plots)
healthy = sum(1 for p in plots if p.status in ("healthy", "normal"))
healthy_rate = round((healthy / total_plots) * 100, 1)
# 3. 统计分析病害分布
disease_count = {}
for r in records:
disease_count[r.disease_name] = \
disease_count.get(r.disease_name, 0) + 1
# 4. 调用AI文本模型生成报告摘要
client = OpenAI(api_key=settings.QWEN_API_KEY,
base_url=settings.QWEN_BASE_URL)
prompt = f"请写一份玫瑰园{period_cn},包含以下数据:..."
response = client.chat.completions.create(
model=TEXT_POOL[0], # 文本模型池
messages=[{"role": "user", "content": prompt}]
)
ai_summary = response.choices[0].message.content
# 5. 渲染 HTML 模板
env = Environment(loader=FileSystemLoader("templates"))
template = env.get_template("report.html")
html = template.render(
title=f"玫瑰花园{period_cn}",
period=period_cn,
healthy_rate=healthy_rate,
disease_stats=disease_stats,
ai_summary=ai_summary,
...
)
return html
前端以 blob 形式下载:
javascript
async function exportReport(period) {
const res = await api.get(`/reports/export?period=${period}`,
{ responseType: 'blob' })
const url = window.URL.createObjectURL(new Blob([res.data]))
const a = document.createElement('a')
a.href = url
a.download = `玫瑰园区${period === 'weekly' ? '周报' : '月报'}.html`
a.click()
window.URL.revokeObjectURL(url)
}
八、部署
后端一行命令启动,SQLite 零配置。
bash
# 后端
cd backend
pip install -r requirements.txt
cp .env.example .env # 填入阿里云百炼 API Key
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
# 前端
cd frontend
npm install
npx vite --host 0.0.0.0 --port 5173
演示账号:赵老板 13831000001 / 密码 123456
九、设计取舍
3D 园区地图做了三版。 从 Three.js 真实 3D -> 2.5D 等距 -> 最终 2D 网格卡片。每一版技术更"弱",但实际效果更好。园区管理员的真实需求是"快速找到有问题的地块",不是"欣赏 3D 渲染效果"。
SQLite 而非 PostgreSQL。 MVP 阶段零运维的好处远大于理论上的性能上限,且 SQLAlchemy ORM 只需改连接串就能迁移。
模型池而非固定模型。 阿里云百炼的免费额度模型不稳定,固定一个模型会频繁遇到"今日配额用完"的报错。模型池配合遍历重试,做到了零运维。
WebSocket 而非轮询。 大屏数据推送场景 WebSocket 更省流量、延迟更低,实现复杂度差不多。