项目摘要

本项目旨在设计并实现一个基于深度学习与Web技术的香蕉成熟度智能识别与检测系统。系统核心采用前沿的YOLO系列目标检测模型(包括YOLOv8, v10, v11, v12),实现对香蕉图像的快速、精准成熟度分类。后端使用SpringBoot框架构建RESTful API,前端与后端分离,提供友好的Web交互界面。系统集成了用户认证、多模型切换、多种检测模式(图像、视频、实时摄像头)、检测记录管理、数据可视化以及管理员后台等完整功能。通过结合DeepSeek等AI分析能力,该系统不仅是一个高效的计算机视觉应用,更是一个功能完备、可扩展的智能农业管理平台。


1. 引言

随着现代农业和智能分选产业的发展,对农产品品质进行自动化、无损化检测的需求日益迫切。香蕉作为一种典型的呼吸跃变型水果,其成熟度直接决定了经济价值和市场流向。传统的人工分选方法效率低下、主观性强且成本高昂。因此,开发一套能够自动、准确识别香蕉成熟度的系统具有重要的现实意义。

本项目正是在此背景下应运而生。我们利用计算机视觉领域最先进的YOLO目标检测算法,结合现代化的Web开发技术栈,构建了一个前后端分离的香蕉成熟度识别系统。该系统不仅提供了强大的核心检测功能,还通过精心设计的用户界面和后台管理模块,确保了系统的易用性、可管理性和可扩展性,为香蕉的智能化质量监控和分级提供了一个完整的解决方案。


目录

 项目摘要

1. 引言

2. 项目背景

3. 数据集介绍

功能模块

登录注册模块

可视化模块

更换导航栏背景颜色

图像检测模块

视频检测模块

实时检测模块

图片识别记录管理

视频识别记录管理

摄像头识别记录管理

用户管理模块

数据管理模块(MySQL表设计)

模型训练结果

YOLOv8

YOLOv10

YOLOv11

YOLOv12

前端代码展示

后端代码展示


项目源码+数据集下载链接

完整代码在哔哩哔哩视频下方简介内获取

基于YOLOv8/v10/v11/v12与SpringBoot的前后端分离香蕉成熟度识别检测系统(DeepSeek智能分析+web交互界面)_哔哩哔哩_bilibili

基于YOLOv8/v10/v11/v12与SpringBoot的前后端分离香蕉成熟度识别检测系统(DeepSeek智能分析+web交互界面)_哔哩哔哩_bilibilihttps://www.bilibili.com/video/BV158k6BVEw1/?vd_source=549d0b4e2b8999929a61a037fcce3b0f

https://www.bilibili.com/video/BV158k6BVEw1

2. 项目背景

2.1 技术背景:

  • 深度学习在农业领域的应用:近年来,卷积神经网络(CNN)特别是单阶段目标检测模型(如YOLO系列)在图像分类、目标检测任务中表现出色,已被广泛应用于植物病害识别、果实计数、成熟度判断等农业场景。

  • YOLO算法的演进:从YOLOv8开始,其社区活跃,衍生出了多种改进版本(如YOLOv10的无需NMS设计,v11/v12在精度和速度上的持续优化),为开发者提供了丰富的模型选择,以适应不同的精度与速度需求。

  • Web技术的成熟:SpringBoot作为Java领域最流行的后端框架,以其简洁、高效的特性,非常适合构建稳健的微服务和API。前后端分离的架构模式使得前端UI与后端业务逻辑解耦,提升了开发效率和系统可维护性。

2.2 业务背景:
在香蕉的采摘、仓储、物流和销售环节中,对不同成熟度的香蕉进行分级处理是保证产品质量、减少损耗的关键。一个自动化的识别系统可以:

  • 提高效率:替代重复性的人工分拣工作。

  • 统一标准:避免因人工判断差异导致的分级不统一。

  • 数据化管理:将检测结果保存入库,便于进行质量追溯和数据分析。


3. 数据集介绍

本项目的核心是训练一个能够精准识别香蕉成熟度的模型,因此一个高质量、标注准确的数据集至关重要。

3.1 数据集来源与构成:
本项目所使用的数据集是一个专门为香蕉成熟度检测任务构建的图像集合。数据集中包含了在不同光照条件、不同角度、不同背景下拍摄的香蕉图像,确保了模型的泛化能力。图像中的香蕉被精细地标注为以下几个代表不同成熟度的类别:

  1. 未成熟:香蕉表皮为绿色,质地坚硬。

  2. 半成熟:香蕉表皮黄绿相间,开始变软。

  3. 成熟:香蕉表皮为亮黄色,质地柔软,最适合食用。

  4. 过熟:香蕉表皮出现大量褐色斑点或完全变褐,开始软化变质。

3.2 数据标注与格式:
所有图像均使用专业的标注工具(如LabelImg)进行了边界框标注,标注文件为标准化的YOLO格式(.txt文件),其中每个文件包含对应图片中所有香蕉实例的类别索引和归一化后的边界框坐标(<class_id> <x_center> <y_center> <width> <height>)。这种格式与我们所采用的YOLOv8/v10/v11/v12模型训练要求完全兼容。

功能模块


✅ 用户登录注册:支持密码检测,保存到MySQL数据库。

✅ 支持四种YOLO模型切换,YOLOv8、YOLOv10、YOLOv11、YOLOv12。

✅ 信息可视化,数据可视化。

✅ 图片检测支持AI分析功能,deepseek

✅ 支持图像检测、视频检测和摄像头实时检测,检测结果保存到MySQL数据库。

✅ 图片识别记录管理、视频识别记录管理和摄像头识别记录管理。

✅ 用户管理模块,管理员可以对用户进行增删改查。

✅ 个人中心,可以修改自己的信息,密码姓名头像等等。

✅ 支持更换导航栏背景颜色
 

登录注册模块

可视化模块

更换导航栏背景颜色

图像检测模块

  • YOLO模型集成 (v8/v10/v11/v12)

  • DeepSeek多模态分析

  • 支持格式:JPG/PNG/MP4/RTSP

视频检测模块

实时检测模块

图片识别记录管理

视频识别记录管理

摄像头识别记录管理

用户管理模块

数据管理模块(MySQL表设计)

  • users - 用户信息表

  • imgrecords- 图片检测记录表

  • videorecords- 视频检测记录表

  • camerarecords- 摄像头检测记录表

模型训练结果

#coding:utf-8
#根据实际情况更换模型
# yolon.yaml (nano):轻量化模型,适合嵌入式设备,速度快但精度略低。
# yolos.yaml (small):小模型,适合实时任务。
# yolom.yaml (medium):中等大小模型,兼顾速度和精度。
# yolob.yaml (base):基本版模型,适合大部分应用场景。
# yolol.yaml (large):大型模型,适合对精度要求高的任务。
 
from ultralytics import YOLO
 
model_path = 'pt/yolo12s.pt'
data_path = 'data.yaml'
 
if __name__ == '__main__':
    model = YOLO(model_path)
    results = model.train(data=data_path,
                          epochs=500,
                          batch=64,
                          device='0',
                          workers=0,
                          project='runs',
                          name='exp',
                          )
 
 
 
 
 
 
 
 

YOLOv8

YOLOv10

YOLOv11

YOLOv12

前端代码展示

部分代码

<template>
	<div class="home-container layout-pd">
		<el-row :gutter="15" class="home-card-two mb15">
			<el-col :xs="24" :sm="14" :md="14" :lg="16" :xl="16">
				<div class="home-card-item">
					<div style="height: 100%" ref="homeBarRef"></div>
				</div>
			</el-col>
			<el-col :xs="24" :sm="10" :md="10" :lg="8" :xl="8" class="home-media">
				<div class="home-card-item">
					<div style="height: 100%" ref="homePieRef"></div>
				</div>
			</el-col>
		</el-row>
		<el-row :gutter="15" class="home-card-three">
			<el-col :xs="24" :sm="14" :md="14" :lg="8" :xl="8" class="home-media">
				<div class="home-card-item">
					<div style="height: 100%" ref="homeradarRef"></div>
				</div>
			</el-col>
			<el-col :xs="24" :sm="10" :md="10" :lg="16" :xl="16">
				<div class="home-card-item">
					<div class="home-card-item-title">实时检测记录</div>
					<div class="home-monitor">
						<div class="flex-warp">
							<el-table :data="state.paginatedData" style="width: 100%" height="360" v-loading="state.loading">
								<el-table-column prop="username" label="操作用户" align="center" width="120" />
								<el-table-column prop="label" label="检测结果" align="center" width="120">
									<template #default="scope">
										<el-tag 
											:type="getResultType(scope.row.label)"
											effect="light"
										>
											{{ formatLabel(scope.row.label) }}
										</el-tag>
									</template>
								</el-table-column>
								<el-table-column prop="confidence" label="置信度" align="center" width="120">
									<template #default="scope">
										{{ formatConfidence(scope.row.confidence) }}
									</template>
								</el-table-column>
								<el-table-column prop="weight" label="模型权重" align="center" width="120" />
								<el-table-column prop="conf" label="检测阈值" align="center" width="120" />
								<el-table-column prop="startTime" label="检测时间" align="center" width="180" />
								<el-table-column label="操作" align="center" width="100">
									<template #default="scope">
										<el-button link type="primary" size="small" @click="handleViewDetail(scope.row)">
											详情
										</el-button>
									</template>
								</el-table-column>
							</el-table>
							<div class="pagination-container">
								<el-pagination
									v-model:current-page="state.currentPage"
									v-model:page-size="state.pageSize"
									:page-sizes="[10, 20, 50, 100]"
									:small="true"
									:layout="layout"
									:total="state.total"
									@size-change="handleSizeChange"
									@current-change="handleCurrentChange"
								/>
							</div>
						</div>
					</div>
				</div>
			</el-col>
		</el-row>
		<el-row :gutter="15" class="home-card-three" style="margin-top: 15px;">
			<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
				<div class="home-card-item">
					<div style="height: 100%" ref="homeLineRef"></div>
				</div>
			</el-col>
		</el-row>

		<!-- 详情弹窗 -->
		<el-dialog
			v-model="state.detailDialogVisible"
			:title="`检测记录详情 - ${state.selectedRecord?.username || ''}`"
			width="80%"
			:close-on-click-modal="false"
			:close-on-press-escape="false"
			center
		>
			<div class="detail-container" v-loading="state.detailLoading">
				<el-row :gutter="20">
					<!-- 检测图片 -->
					<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12">
						<div class="detail-section">
							<h3 class="detail-title">原始图片</h3>
							<div class="image-container">
								<div class="img-wrapper" @click="previewImage(getImageUrl(state.selectedRecord?.inputImg), '原始图片')">
									<img 
										:src="getImageUrl(state.selectedRecord?.inputImg)" 
										alt="原始图片" 
										class="detection-image"
										v-if="state.selectedRecord?.inputImg"
									/>
									<div class="img-overlay" v-if="state.selectedRecord?.inputImg">
										<el-icon><View /></el-icon>
									</div>
									<div v-else class="image-placeholder">
										<el-icon><Picture /></el-icon>
										<span>暂无原始图片</span>
									</div>
								</div>
							</div>
						</div>
					</el-col>
					
					<!-- 检测信息 -->
					<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12">
						<div class="detail-section">
							<h3 class="detail-title">检测信息</h3>
							<el-descriptions :column="1" border>
								<el-descriptions-item label="操作用户">
									{{ state.selectedRecord?.username || '未知' }}
								</el-descriptions-item>
								
								<el-descriptions-item label="检测结果">
									<el-tag 
										:type="getResultType(state.selectedRecord?.label || '')"
										effect="light"
									>
										{{ formatLabel(state.selectedRecord?.label || '') }}
									</el-tag>
								</el-descriptions-item>
								
								<el-descriptions-item label="置信度">
									{{ formatConfidence(state.selectedRecord?.confidence || '') }}
								</el-descriptions-item>
								
								<el-descriptions-item label="模型权重">
									{{ state.selectedRecord?.weight || '未知' }}
								</el-descriptions-item>
								
								<el-descriptions-item label="检测阈值">
									{{ state.selectedRecord?.conf || '未知' }}
								</el-descriptions-item>
								
								<el-descriptions-item label="检测时间">
									{{ state.selectedRecord?.startTime || '未知' }}
								</el-descriptions-item>
								
								<el-descriptions-item label="检测详情" v-if="hasDetectionDetails">
									<div class="detection-details">
										<div 
											v-for="(item, index) in getDetectionDetails()" 
											:key="index"
											class="detail-item"
										>
											<span class="detail-label">{{ item.label }}:</span>
											<span class="detail-value">{{ item.confidence }}</span>
										</div>
									</div>
								</el-descriptions-item>
							</el-descriptions>
						</div>
					</el-col>
				</el-row>
				
				<!-- 原图与检测结果对比 -->
				<el-row :gutter="20" v-if="state.selectedRecord?.inputImg || state.selectedRecord?.outImg">
					<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12">
						<div class="detail-section">
							<h3 class="detail-title">原始图片</h3>
							<div class="image-container">
								<div class="img-wrapper" @click="previewImage(getImageUrl(state.selectedRecord.inputImg), '原始图片')">
									<img 
										:src="getImageUrl(state.selectedRecord.inputImg)" 
										alt="原始图片" 
										class="detection-image"
										v-if="state.selectedRecord?.inputImg"
									/>
									<div class="img-overlay" v-if="state.selectedRecord?.inputImg">
										<el-icon><View /></el-icon>
									</div>
									<div v-else class="image-placeholder">
										<el-icon><Picture /></el-icon>
										<span>暂无原始图片</span>
									</div>
								</div>
							</div>
						</div>
					</el-col>
					<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12">
						<div class="detail-section">
							<h3 class="detail-title">预测图片</h3>
							<div class="image-container">
								<div class="img-wrapper" @click="previewImage(getImageUrl(state.selectedRecord.outImg), '预测图片')">
									<img 
										:src="getImageUrl(state.selectedRecord.outImg)" 
										alt="预测图片" 
										class="detection-image"
										v-if="state.selectedRecord?.outImg"
									/>
									<div class="img-overlay" v-if="state.selectedRecord?.outImg">
										<el-icon><View /></el-icon>
									</div>
									<div v-else class="image-placeholder">
										<el-icon><Picture /></el-icon>
										<span>暂无预测图片</span>
									</div>
								</div>
							</div>
						</div>
					</el-col>
				</el-row>
			</div>
			
			<template #footer>
				<span class="dialog-footer">
					<el-button @click="state.detailDialogVisible = false">关闭</el-button>
					<el-button type="primary" @click="handleDownloadImage" :disabled="!state.selectedRecord?.inputImg">
						<el-icon><Download /></el-icon>
						下载检测图片
					</el-button>
				</span>
			</template>
		</el-dialog>

		<!-- 图片预览弹窗 -->
		<el-dialog 
			v-model="state.previewDialog.visible" 
			:title="state.previewDialog.title" 
			width="60%"
			align-center
			class="image-preview-dialog">
			<div class="preview-content">
				<img :src="state.previewDialog.imageUrl" :alt="state.previewDialog.title" class="preview-image" />
			</div>
		</el-dialog>
	</div>
</template>

<script setup lang="ts" name="home">
import { reactive, onMounted, ref, watch, nextTick, onActivated, markRaw, computed } from 'vue';
import * as echarts from 'echarts';
import { storeToRefs } from 'pinia';
import { useThemeConfig } from '/@/stores/themeConfig';
import { useTagsViewRoutes } from '/@/stores/tagsViewRoutes';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Picture, Download, View } from '@element-plus/icons-vue';
import request from '/@/utils/request';

// 定义变量内容
const homeLineRef = ref();
const homePieRef = ref();
const homeBarRef = ref();
const homeradarRef = ref();
const storesTagsViewRoutes = useTagsViewRoutes();
const storesThemeConfig = useThemeConfig();
const { themeConfig } = storeToRefs(storesThemeConfig);
const { isTagsViewCurrenFull } = storeToRefs(storesTagsViewRoutes);

const state = reactive({
	data: [] as any,
	paginatedData: [] as any,
	loading: false,
	currentPage: 1,
	pageSize: 10,
	total: 0,
	global: {
		homeChartOne: null,
		homeChartTwo: null,
		homeCharThree: null,
		homeCharFour: null,
		dispose: [null, '', undefined],
	} as any,
	myCharts: [] as EmptyArrayType,
	charts: {
		theme: '',
		bgColor: '',
		color: '#303133',
	},
	// 详情弹窗相关
	detailDialogVisible: false,
	detailLoading: false,
	selectedRecord: null as any,
	// 图片预览弹窗
	previewDialog: {
		visible: false,
		title: '',
		imageUrl: '',
	},
});



const bananaClasses = ['新鲜成熟', '新鲜未熟', '过熟', '成熟', '腐烂', '未成熟'];
// 香蕉成熟度中文映射
const bananaClassMap = {
	'新鲜成熟': '新鲜成熟',
	'新鲜未熟': '新鲜未熟',
	'过熟': '过熟',
	'成熟': '成熟',
	'腐烂': '腐烂',
	'未成熟': '未熟'
};




// 香蕉成熟度颜色映射
const bananaClassColors = {
	'新鲜成熟': '#4CAF50', // 绿色
	'新鲜未熟': '#2196F3', // 蓝色
	'过熟': '#FF9800', // 橙色
	'成熟': '#FFC107', // 黄色
	'腐烂': '#F44336', // 红色
	'未成熟': '#9C27B0' // 紫色
};

// 响应式分页数据
const layout = computed(() => {
	return window.innerWidth < 768 ? 'prev, pager, next' : 'total, sizes, prev, pager, next, jumper';
});

// 获取图片URL - 与图片记录页面保持一致
const getImageUrl = (imagePath: string) => {
	if (!imagePath) return '';
	// 如果已经是完整URL,直接返回
	if (imagePath.startsWith('http')) return imagePath;
	// 否则拼接基础URL - 与图片记录页面保持一致
	return `/api${imagePath.startsWith('/') ? '' : '/'}${imagePath}`;
};

// 是否有检测详情
const hasDetectionDetails = computed(() => {
	if (!state.selectedRecord) return false;
	try {
		const labels = JSON.parse(state.selectedRecord.label || '[]');
		const confidences = JSON.parse(state.selectedRecord.confidence || '[]');
		return labels.length > 0 && confidences.length > 0;
	} catch {
		return false;
	}
});

// 获取检测详情
const getDetectionDetails = () => {
	if (!state.selectedRecord) return [];
	try {
		const labels = JSON.parse(state.selectedRecord.label || '[]');
		const confidences = JSON.parse(state.selectedRecord.confidence || '[]');
		
		return labels.map((label: string, index: number) => ({
			label: bananaClassMap[label as keyof typeof bananaClassMap] || label,
			confidence: confidences[index] ? `${(parseFloat(confidences[index]) * 100).toFixed(1)}%` : '0%'
		}));
	} catch {
		return [];
	}
};

// 图片预览
const previewImage = (imageUrl: string, title: string) => {
	if (!imageUrl) {
		ElMessage.warning('没有可预览的图片');
		return;
	}
	state.previewDialog.imageUrl = imageUrl;
	state.previewDialog.title = title;
	state.previewDialog.visible = true;
};

// 分页处理
const handleSizeChange = (val: number) => {
	state.pageSize = val;
	state.currentPage = 1;
	updatePaginatedData();
};

const handleCurrentChange = (val: number) => {
	state.currentPage = val;
	updatePaginatedData();
};

const updatePaginatedData = () => {
	const start = (state.currentPage - 1) * state.pageSize;
	const end = start + state.pageSize;
	state.paginatedData = state.data.slice(start, end);
};

// 格式化标签显示
const formatLabel = (label: string) => {
	try {
		const labels = JSON.parse(label);
		if (labels.length === 0) return '未检测';
		
		// 将英文标签转换为中文
		const chineseLabels = labels.map((l: string) => 
			bananaClassMap[l as keyof typeof bananaClassMap] || l
		);
		return chineseLabels.join(', ');
	} catch {
		// 如果是单个字符串标签,也尝试转换
		return bananaClassMap[label as keyof typeof bananaClassMap] || label || '未检测';
	}
};

// 根据检测结果设置标签类型
const getResultType = (label: string) => {
	try {
		const labels = JSON.parse(label);
		if (labels.includes('腐烂') || labels.includes('腐烂')) {
			return 'danger'; // 腐烂用红色
		}
		if (labels.includes('过熟') || labels.includes('过熟')) {
			return 'warning'; // 过熟用橙色
		}
		if (labels.includes('成熟') || labels.includes('新鲜成熟') || labels.includes('成熟') || labels.includes('新鲜成熟')) {
			return 'success'; // 成熟用绿色
		}
		if (labels.length > 0) {
			return 'info'; // 其他状态用蓝色
		}
		return 'info'; // 默认用蓝色
	} catch {
		// 如果是单个字符串标签
		if (label.includes('腐烂') || label.includes('腐烂')) {
			return 'danger';
		}
		if (label.includes('过熟') || label.includes('过熟')) {
			return 'warning';
		}
		if (label.includes('成熟') || label.includes('新鲜成熟') || label.includes('成熟') || label.includes('新鲜成熟')) {
			return 'success';
		}
		return 'info';
	}
};

// 格式化置信度显示
const formatConfidence = (confidence: string) => {
	try {
		const confidences = JSON.parse(confidence);
		if (confidences.length === 0) return '0%';
		
		const maxConfidence = Math.max(...confidences.map((conf: any) => {
			if (typeof conf === 'number') return conf * 100;
			if (typeof conf === 'string') {
				const num = parseFloat(conf.replace('%', ''));
				return isNaN(num) ? 0 : num;
			}
			return 0;
		}));
		return `${maxConfidence.toFixed(1)}%`;
	} catch {
		// 如果解析失败,尝试直接显示
		if (typeof confidence === 'number') {
			return `${(confidence * 100).toFixed(1)}%`;
		}
		return confidence || '0%';
	}
};

// 查看详情
const handleViewDetail = async (row: any) => {
	state.selectedRecord = row;
	state.detailDialogVisible = true;
	state.detailLoading = true;
	
	// 如果需要从服务器获取更详细的数据
	try {
		const res = await request.get(`/api/imgRecords/${row.id}`);
		if (res.code == 0) {
			// 确保数据格式一致
			const record = res.data;
			state.selectedRecord = {
				...record,
				// 确保图片字段正确
				inputImg: record.inputImg || record.imagePath,
				outImg: record.outImg || record.resultImagePath
			};
		}
	} catch (error) {
		console.error('获取详情失败:', error);
		// 如果API调用失败,使用已有数据
		state.selectedRecord = row;
	} finally {
		state.detailLoading = false;
	}
};

// 下载图片
const handleDownloadImage = async () => {
	if (!state.selectedRecord?.inputImg) {
		ElMessage.warning('没有可下载的图片');
		return;
	}
	
	try {
		const imageUrl = getImageUrl(state.selectedRecord.inputImg);
		const response = await fetch(imageUrl);
		const blob = await response.blob();
		
		const url = window.URL.createObjectURL(blob);
		const a = document.createElement('a');
		a.href = url;
		
		// 从路径中提取文件名,如果没有则使用默认名称
		const filename = state.selectedRecord.inputImg.split('/').pop() || 
			`detection_${state.selectedRecord.username}_${state.selectedRecord.startTime?.replace(/[: ]/g, '-') || 'unknown'}.jpg`;
		
		a.download = filename;
		document.body.appendChild(a);
		a.click();
		window.URL.revokeObjectURL(url);
		document.body.removeChild(a);
		
		ElMessage.success('图片下载成功');
	} catch (error) {
		console.error('下载图片失败:', error);
		ElMessage.error('图片下载失败');
	}
};

// 折线图 - 近十日预测数量
const initLineChart = () => {
	if (!state.global.dispose.some((b: any) => b === state.global.homeChartOne)) state.global.homeChartOne?.dispose();
	state.global.homeChartOne = markRaw(echarts.init(homeLineRef.value, state.charts.theme));
	
	// 统计每天的预测数量
	const counts: Record<string, number> = {};
	state.data.forEach((prediction: any) => {
		if (prediction.startTime) {
			const date = prediction.startTime.split(' ')[0];
			counts[date] = (counts[date] || 0) + 1;
		}
	});

	const sortedDatesDesc = Object.keys(counts).sort((a, b) => b.localeCompare(a));
	const latestDatesDesc = sortedDatesDesc.slice(0, 10);
	const latestDates = latestDatesDesc.sort((a, b) => a.localeCompare(b));

	const result = {
		dateData: latestDates,
		valueData: latestDates.map(date => counts[date])
	};

	const option = {
		backgroundColor: state.charts.bgColor,
		title: {
			text: '近十日检测数量趋势',
			x: 'left',
			textStyle: { fontSize: 15, color: state.charts.color },
		},
		grid: { top: 70, right: 20, bottom: 30, left: 30 },
		tooltip: { 
			trigger: 'axis',
			formatter: (params: any) => {
				const data = params[0];
				return `${data.name}<br/>检测数量: ${data.value}`;
			}
		},
		xAxis: {
			data: result.dateData,
			axisLabel: {
				color: state.charts.color,
				rotate: 45
			},
		},
		yAxis: [
			{
				type: 'value',
				name: '检测数量',
				splitLine: { show: true, lineStyle: { type: 'dashed', color: state.charts.theme === 'dark' ? '#444' : '#f5f5f5' } },
				axisLabel: {
					color: state.charts.color,
				},
			},
		],
		series: [
			{
				name: '检测数量',
				type: 'line',
				symbolSize: 6,
				symbol: 'circle',
				smooth: true,
				data: result.valueData,
				lineStyle: { color: '#FF6B6B' },
				itemStyle: { color: '#FF6B6B', borderColor: '#FF6B6B' },
				areaStyle: {
					color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
						{ offset: 0, color: '#FF6B6Bb3' },
						{ offset: 1, color: '#FF6B6B03' },
					]),
				},
			},
		],
	};

	state.global.homeChartOne.setOption(option);
	state.myCharts.push(state.global.homeChartOne);
};

// 饼图 - 用户检测分布
const initPieChart = () => {
	if (!state.global.dispose.some((b: any) => b === state.global.homeChartTwo)) state.global.homeChartTwo?.dispose();
	state.global.homeChartTwo = markRaw(echarts.init(homePieRef.value, state.charts.theme));
	
	const usernameCounts: Record<string, number> = {};
	state.data.forEach((prediction: any) => {
		const username = prediction.username || '未知用户';
		usernameCounts[username] = (usernameCounts[username] || 0) + 1;
	});

	const sortedUsernames = Object.keys(usernameCounts).sort((a, b) => usernameCounts[b] - usernameCounts[a]);
	const topUsernames = sortedUsernames.slice(0, 6);
	const topValues = topUsernames.map(u => usernameCounts[u]);

	const pieData = topUsernames.map((username, i) => ({
		name: username,
		value: topValues[i]
	}));

	const option = {
		backgroundColor: state.charts.bgColor,
		title: {
			text: '用户检测分布',
			x: 'left',
			textStyle: { fontSize: '15', color: state.charts.color },
		},
		legend: {
			top: 'bottom',
			textStyle: {
				color: state.charts.color
			}
		},
		tooltip: {
			trigger: 'item',
			formatter: '{a} <br/>{b}: {c} ({d}%)'
		},
		series: [
			{
				type: 'pie',
				radius: ['40%', '70%'],
				center: ['50%', '50%'],
				avoidLabelOverlap: true,
				itemStyle: {
					borderRadius: 10,
					borderColor: state.charts.bgColor,
					borderWidth: 2
				},
				label: {
					show: true,
					formatter: '{b}: {c}次',
					color: state.charts.color
				},
				emphasis: {
					label: {
						show: true,
						fontSize: '14',
						fontWeight: 'bold'
					}
				},
				data: pieData
			}
		]
	};

	state.global.homeChartTwo.setOption(option);
	state.myCharts.push(state.global.homeChartTwo);
};

// 雷达图 - 用户置信度分析
const initradarChart = () => {
	if (!state.global.dispose.some((b: any) => b === state.global.homeCharFour)) state.global.homeCharFour?.dispose();
	state.global.homeCharFour = markRaw(echarts.init(homeradarRef.value, state.charts.theme));
	
	const confStatsByUser: Record<string, { total: number, count: number }> = {};
	
	state.data.forEach((prediction: any) => {
		const username = prediction.username || '未知用户';
		let confidenceValue = 0;
		
		try {
			const confidences = JSON.parse(prediction.confidence || '[]');
			if (confidences.length > 0) {
				// 取最大置信度
				confidenceValue = Math.max(...confidences.map((conf: any) => {
					if (typeof conf === 'number') return conf;
					if (typeof conf === 'string') {
						const num = parseFloat(conf.replace('%', '')) / 100;
						return isNaN(num) ? 0 : num;
					}
					return 0;
				}));
			}
		} catch {
			// 如果解析失败,尝试直接使用数值
			if (typeof prediction.confidence === 'number') {
				confidenceValue = prediction.confidence;
			}
		}
		
		if (!confStatsByUser[username]) {
			confStatsByUser[username] = { total: confidenceValue, count: 1 };
		} else {
			confStatsByUser[username].total += confidenceValue;
			confStatsByUser[username].count += 1;
		}
	});

	const avgConfData = Object.keys(confStatsByUser).map(username => ({
		username,
		avgConf: confStatsByUser[username].total / confStatsByUser[username].count,
	}));

	const topAvgConfData = avgConfData.slice(0, 7);
	const data = topAvgConfData.map(item => Number((item.avgConf * 100).toFixed(2)));
	const indicatorNames = topAvgConfData.map(item => item.username);

	const indicator = indicatorNames.map((name) => ({ 
		name, 
		max: 100 
	}));

	const option = {
		backgroundColor: state.charts.bgColor,
		title: {
			text: '用户检测置信度分析',
			x: 'left',
			textStyle: { fontSize: '15', color: state.charts.color },
		},
		tooltip: {
			formatter: (params: any) => {
				return `${params.name}: ${params.value}%`;
			}
		},
		radar: {
			radius: '65%',
			splitNumber: 4,
			indicator: indicator,
			axisName: {
				color: state.charts.color,
				fontSize: 12
			},
			splitArea: {
				areaStyle: {
					color: ['rgba(255,107,107,0.1)', 'rgba(255,107,107,0.05)'],
				}
			},
			splitLine: {
				lineStyle: {
					color: 'rgba(255,107,107,0.3)'
				}
			},
			axisLine: {
				lineStyle: {
					color: 'rgba(255,107,107,0.5)'
				}
			}
		},
		series: [{
			type: 'radar',
			data: [{
				value: data,
				name: '置信度',
				areaStyle: {
					color: 'rgba(255,107,107,0.3)'
				},
				lineStyle: {
					color: '#FF6B6B'
				},
				itemStyle: {
					color: '#FF6B6B'
				},
				label: {
					show: true,
					formatter: (params: any) => {
						return params.value + '%';
					}
				}
			}]
		}]
	};

	state.global.homeCharFour.setOption(option);
	state.myCharts.push(state.global.homeCharFour);
};

// 柱状图 - 香蕉成熟度检测结果统计
const initBarChart = () => {
	if (!state.global.dispose.some((b: any) => b === state.global.homeCharThree)) state.global.homeCharThree?.dispose();
	state.global.homeCharThree = markRaw(echarts.init(homeBarRef.value, state.charts.theme));
	
	// 使用香蕉成熟度类别
	const categories = bananaClasses.map(cls => bananaClassMap[cls as keyof typeof bananaClassMap]);
	const counts: Record<string, number> = {};
	
	// 初始化计数
	bananaClasses.forEach(cls => {
		counts[cls] = 0;
	});

	// 统计每个类别的数量
	state.data.forEach((item: any) => {
		let detectedClasses: string[] = [];
		
		try {
			// 尝试解析JSON格式的标签
			detectedClasses = JSON.parse(item.label || '[]');
		} catch {
			// 如果解析失败,检查原始label字段
			const label = item.label || '';
			if (label) {
				// 尝试分割字符串
				detectedClasses = label.split(',').map((l: string) => l.trim());
			}
		}
		
		// 统计检测到的类别
		if (detectedClasses.length > 0) {
			detectedClasses.forEach((cls: string) => {
				const normalizedClass = cls.toLowerCase().trim();
				if (bananaClasses.includes(normalizedClass)) {
					counts[normalizedClass]++;
				} else {
					// 如果类别不在预定义列表中,尝试匹配
					for (const bananaClass of bananaClasses) {
						if (normalizedClass.includes(bananaClass) || bananaClass.includes(normalizedClass)) {
							counts[bananaClass]++;
							break;
						}
					}
				}
			});
		} else {
			// 如果没有检测到任何类别,统计为未检测
			counts['未成熟'] = (counts['未成熟'] || 0) + 1;
		}
	});

	const countData = bananaClasses.map(cls => counts[cls]);
	const colors = bananaClasses.map(cls => bananaClassColors[cls as keyof typeof bananaClassColors]);

	const option = {
		backgroundColor: state.charts.bgColor,
		title: {
			text: '香蕉成熟度检测分布',
			x: 'left',
			textStyle: { fontSize: '15', color: state.charts.color },
		},
		tooltip: { 
			trigger: 'axis',
			formatter: (params: any) => {
				const data = params[0];
				return `${data.name}<br/>数量: ${data.value}`;
			}
		},
		grid: { top: 70, right: 30, bottom: 30, left: 50 },
		xAxis: [
			{
				type: 'category',
				data: categories,
				axisTick: { show: false },
				axisLabel: {
					color: state.charts.color,
					rotate: 45, // 如果文字太长可以旋转
				},
			},
		],
		yAxis: [
			{
				type: 'value',
				name: '检测数量',
				splitLine: { 
					show: true, 
					lineStyle: { 
						type: 'dashed', 
						color: state.charts.theme === 'dark' ? '#444' : '#f5f5f5' 
					} 
				},
				axisLabel: {
					color: state.charts.color,
				},
			},
		],
		series: [
			{
				name: '检测数量',
				type: 'bar',
				barWidth: 40,
				itemStyle: {
					color: (params: any) => colors[params.dataIndex],
					borderRadius: [4, 4, 0, 0],
				},
				label: {
					show: true,
					position: 'top',
					color: state.charts.color
				},
				data: countData,
			},
		],
	};
	
	state.global.homeCharThree.setOption(option);
	state.myCharts.push(state.global.homeCharThree);
};

// 批量设置 echarts resize
const initEchartsResizeFun = () => {
	nextTick(() => {
		for (let i = 0; i < state.myCharts.length; i++) {
			setTimeout(() => {
				state.myCharts[i]?.resize();
			}, i * 1000);
		}
	});
};

const initEchartsResize = () => {
	window.addEventListener('resize', initEchartsResizeFun);
};

// 加载数据
const loadData = async () => {
	state.loading = true;
	try {
		const res = await request.get('/api/imgRecords/all');
		if (res.code == 0) {
			// 转换数据格式,确保与图片记录页面一致
			state.data = res.data.map((record: any, index: number) => {
				// 统一数据格式
				const transformedRecord = {
					id: record.id,
					num: index + 1,
					// 图片字段统一
					inputImg: record.inputImg || record.imagePath,
					outImg: record.outImg || record.resultImagePath,
					// 其他字段
					weight: record.weight,
					conf: record.conf,
					ai: record.ai,
					suggestion: record.suggestion,
					startTime: record.startTime,
					username: record.username,
					label: record.label,
					confidence: record.confidence,
					// 确保family字段存在
					family: record.family || []
				};
				
				// 如果没有family字段,尝试从label和confidence构建
				if (!transformedRecord.family || transformedRecord.family.length === 0) {
					try {
						const labels = JSON.parse(record.label || '[]');
						const confidences = JSON.parse(record.confidence || '[]');
						transformedRecord.family = labels.map((label: string, idx: number) => ({
							label: label,
							confidence: confidences[idx] || 0,
							startTime: record.startTime
						}));
					} catch (error) {
						console.error('构建family字段失败:', error);
						transformedRecord.family = [];
					}
				}
				
				return transformedRecord;
			}).reverse();
			
			state.total = state.data.length;
			updatePaginatedData();
			
			// 初始化图表
			setTimeout(() => {
				initLineChart();
				initradarChart();
				initPieChart();
				initBarChart();
			}, 100);
		} else {
			ElMessage.error(res.msg || '加载数据失败');
		}
	} catch (error) {
		console.error('加载数据失败:', error);
		ElMessage.error('加载数据失败,请检查网络连接');
	} finally {
		state.loading = false;
	}
};

// 页面加载时
onMounted(() => {
	loadData();
	initEchartsResize();
});

// 由于页面缓存原因,keep-alive
onActivated(() => {
	initEchartsResizeFun();
});

// 监听相关状态变化
watch(
	() => isTagsViewCurrenFull.value,
	() => {
		initEchartsResizeFun();
	}
);

watch(
	() => themeConfig.value.isIsDark,
	(isIsDark) => {
		nextTick(() => {
			state.charts.theme = isIsDark ? 'dark' : '';
			state.charts.bgColor = isIsDark ? 'transparent' : '';
			state.charts.color = isIsDark ? '#dadada' : '#303133';
			setTimeout(() => {
				initLineChart();
				initradarChart();
				initPieChart();
				initBarChart();
			}, 500);
		});
	},
	{
		deep: true,
		immediate: true,
	}
);
</script>

<style scoped lang="scss">
$homeNavLengh: 8;

.home-container {
	overflow: hidden;

	.home-card-one,
	.home-card-two,
	.home-card-three {
		.home-card-item {
			width: 100%;
			height: 130px;
			border-radius: 4px;
			transition: all ease 0.3s;
			padding: 20px;
			overflow: hidden;
			background: var(--el-color-white);
			color: var(--el-text-color-primary);
			border: 1px solid var(--next-border-color-light);

			&:hover {
				box-shadow: 0 2px 12px var(--next-color-dark-hover);
				transition: all ease 0.3s;
			}

			&-icon {
				width: 70px;
				height: 70px;
				border-radius: 100%;
				flex-shrink: 1;

				i {
					color: var(--el-text-color-placeholder);
				}
			}

			&-title {
				font-size: 15px;
				font-weight: bold;
				height: 30px;
				margin-bottom: 15px;
				color: var(--el-text-color-primary);
				border-bottom: 1px solid var(--next-border-color-light);
				padding-bottom: 10px;
			}
		}
	}

	.home-card-two,
	.home-card-three {
		.home-card-item {
			height: 400px;
			width: 100%;
			overflow: hidden;

			.home-monitor {
				height: 100%;

				.flex-warp-item {
					width: 25%;
					height: 111px;
					display: flex;

					.flex-warp-item-box {
						margin: auto;
						text-align: center;
						color: var(--el-text-color-primary);
						display: flex;
						border-radius: 5px;
						background: var(--next-bg-color);
						cursor: pointer;
						transition: all 0.3s ease;

						&:hover {
							background: var(--el-color-primary-light-9);
							transition: all 0.3s ease;
						}
					}

					@for $i from 0 through $homeNavLengh {
							.home-animation#{$i} {
							opacity: 0;
							animation-name: error-num;
							animation-duration: 0.5s;
							animation-fill-mode: forwards;
							animation-delay: calc($i/10) + s;
						}
					}
				}
			}
		}
	}
}

.pagination-container {
	display: flex;
	justify-content: flex-end;
	margin-top: 15px;
	padding: 10px 0;
}

/* 详情弹窗样式 */
.detail-container {
	padding: 10px 0;
}

.detail-section {
	margin-bottom: 20px;
}

.detail-title {
	font-size: 16px;
	font-weight: bold;
	margin-bottom: 15px;
	color: var(--el-text-color-primary);
	border-left: 4px solid var(--el-color-primary);
	padding-left: 10px;
}

.image-container {
	width: 100%;
	height: 300px;
	display: flex;
	justify-content: center;
	align-items: center;
	border: 1px solid var(--next-border-color-light);
	border-radius: 8px;
	overflow: hidden;
	background-color: var(--el-fill-color-light);
	margin-bottom: 10px;
}

.img-wrapper {
	position: relative;
	display: flex;
	justify-content: center;
	align-items: center;
	cursor: pointer;
	border-radius: 6px;
	overflow: hidden;
	height: 100%;
	width: 100%;
	
	&:hover {
		.img-overlay {
			opacity: 1;
		}
		
		.detection-image {
			transform: scale(1.05);
		}
	}
	
	.detection-image {
		width: 100%;
		height: 100%;
		object-fit: cover;
		border-radius: 4px;
		border: 1px solid var(--next-border-color-light);
		transition: transform 0.3s ease;
	}
	
	.img-overlay {
		position: absolute;
		top: 0;
		left: 0;
		right: 0;
		bottom: 0;
		background: rgba(0, 0, 0, 0.5);
		display: flex;
		justify-content: center;
		align-items: center;
		opacity: 0;
		transition: opacity 0.3s ease;
		
		.el-icon {
			color: white;
			font-size: 24px;
		}
	}
}

.image-error,
.image-placeholder {
	display: flex;
	flex-direction: column;
	align-items: center;
	justify-content: center;
	color: var(--el-text-color-secondary);
	
	.el-icon {
		font-size: 48px;
		margin-bottom: 10px;
	}
}

.image-actions {
	display: flex;
	gap: 10px;
	justify-content: center;
}

.detection-details {
	display: flex;
	flex-direction: column;
	gap: 8px;
}

.detail-item {
	display: flex;
	justify-content: space-between;
	align-items: center;
	padding: 4px 0;
}

.detail-label {
	font-weight: 500;
	color: var(--el-text-color-primary);
}

.detail-value {
	color: var(--el-text-color-regular);
}

.dialog-footer {
	display: flex;
	justify-content: flex-end;
	gap: 10px;
}

// 图片预览弹窗样式
.image-preview-dialog {
	.preview-content {
		display: flex;
		justify-content: center;
		align-items: center;
		
		.preview-image {
			max-width: 100%;
			max-height: 70vh;
			object-fit: contain;
			border-radius: 8px;
		}
	}
}

/* 响应式调整 */
@media (max-width: 768px) {
	.home-media {
		margin-top: 15px;
	}
	
	.pagination-container {
		justify-content: center;
	}
	
	.image-container {
		height: 250px;
	}
	
	.detail-section {
		margin-bottom: 15px;
	}
	
	.image-actions {
		flex-direction: column;
	}
}

@keyframes error-num {
	0% {
		opacity: 0;
		transform: translateY(20px);
	}
	100% {
		opacity: 1;
		transform: translateY(0);
	}
}
</style>

后端代码展示

项目源码+数据集下载链接

完整代码在哔哩哔哩视频下方简介内获取

基于YOLOv8/v10/v11/v12与SpringBoot的前后端分离香蕉成熟度识别检测系统(DeepSeek智能分析+web交互界面)_哔哩哔哩_bilibili

基于YOLOv8/v10/v11/v12与SpringBoot的前后端分离香蕉成熟度识别检测系统(DeepSeek智能分析+web交互界面)_哔哩哔哩_bilibilihttps://www.bilibili.com/video/BV158k6BVEw1/?vd_source=549d0b4e2b8999929a61a037fcce3b0f

https://www.bilibili.com/video/BV158k6BVEw1

 项目安装教程

https://www.bilibili.com/video/BV1YLsXzJE2X/?spm_id_from=333.1387.homepage.video_card.click

YOLO+spring boot+vue项目环境部署教程(YOLOv8、YOLOv10、YOLOv11、YOLOv12)_哔哩哔哩_bilibilihttps://www.bilibili.com/video/BV1YLsXzJE2X/?spm_id_from=333.1387.homepage.video_card.click&vd_source=549d0b4e2b8999929a61a037fcce3b0f

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐