玫瑰花园管理系统:AI识病+3D可视化,一套面向中小型玫瑰种植园的数字化管理工具

玫瑰花园管理系统: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 更省流量、延迟更低,实现复杂度差不多。

相关推荐
延凡科技1 小时前
多场景落地复盘:端边云架构无人机智能巡检系统设计与实践
大数据·数据结构·人工智能·科技·架构·无人机·能源
科技圈观察1 小时前
壹沓科技WAIC全球首发小沓OS与小沓KE,重塑供应链AI基础设施
人工智能·科技
CV-Climber2 小时前
检索技术的实际应用
人工智能·算法
颜酱2 小时前
01 | 骨架搭建:FastAPI + Vue 跑通第一个 SSE 流式问答
前端·人工智能·后端
龙腾AI白云2 小时前
世界模型被视为破局关键
人工智能·virtualenv·知识图谱·pygame
新知图书2 小时前
10.1 项目背景与需求分析(智能客服智能体开发)
人工智能·agent·ai agent·智能体·扣子
ifenxi爱分析2 小时前
爱分析最新报告解读:AI数据基础设施与数据中台的区别
大数据·人工智能
hhzz2 小时前
Tiger AI Platform平台中增加人脸识别功能
图像处理·人工智能·算法·计算机视觉·大模型
阿里云大数据AI技术2 小时前
Search Lake:ES x Paimon 让湖上多模态数据可搜可用
人工智能·elasticsearch·搜索引擎