wanlinxing commited on
Commit
2af19bc
·
verified ·
1 Parent(s): 6b5d55b

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +632 -0
  2. detector.py +164 -0
  3. visualize.py +293 -0
app.py ADDED
@@ -0,0 +1,632 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 目标检测 Web 应用 - Gradio 界面
3
+ 基于 YOLOv8 预训练模型 (COCO 80类)
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ import gradio as gr
9
+ import cv2
10
+
11
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
12
+
13
+ from detector import Detector
14
+ from visualize import draw_detections as draw_boxes
15
+
16
+ # ============================================================
17
+ # 全局配置
18
+ # ============================================================
19
+ MODEL_NAME = 'yolov8s.pt'
20
+
21
+ print("=" * 60)
22
+ print("智能目标检测系统启动中...")
23
+ print(f"模型: YOLOv8 Small (COCO 80类)")
24
+ print("=" * 60)
25
+
26
+ detector = Detector(model_name=MODEL_NAME)
27
+
28
+
29
+ # ============================================================
30
+ # 样式定义
31
+ # ============================================================
32
+ CUSTOM_CSS = """
33
+ /* === 全局样式 === */
34
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
35
+
36
+ * {
37
+ font-family: 'Inter', 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif !important;
38
+ }
39
+
40
+ .gradio-container {
41
+ max-width: 1400px !important;
42
+ margin: 0 auto !important;
43
+ background: linear-gradient(135deg, #f5f7fa 0%, #e4e9f0 100%) !important;
44
+ }
45
+
46
+ body {
47
+ background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%) !important;
48
+ }
49
+
50
+ /* === 主容器 === */
51
+ .main-container {
52
+ background: rgba(255, 255, 255, 0.95);
53
+ backdrop-filter: blur(20px);
54
+ border-radius: 24px;
55
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04);
56
+ padding: 40px;
57
+ margin: 20px;
58
+ border: 1px solid rgba(255, 255, 255, 0.6);
59
+ }
60
+
61
+ /* === 标题区域 === */
62
+ .header-section {
63
+ text-align: center;
64
+ margin-bottom: 36px;
65
+ position: relative;
66
+ }
67
+
68
+ .header-badge {
69
+ display: inline-block;
70
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
71
+ color: white;
72
+ padding: 6px 20px;
73
+ border-radius: 50px;
74
+ font-size: 13px;
75
+ font-weight: 600;
76
+ letter-spacing: 1.5px;
77
+ text-transform: uppercase;
78
+ margin-bottom: 16px;
79
+ }
80
+
81
+ .header-title {
82
+ font-size: 42px;
83
+ font-weight: 800;
84
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
85
+ -webkit-background-clip: text;
86
+ -webkit-text-fill-color: transparent;
87
+ background-clip: text;
88
+ margin: 12px 0;
89
+ letter-spacing: -0.5px;
90
+ }
91
+
92
+ .header-subtitle {
93
+ font-size: 16px;
94
+ color: #64748b;
95
+ font-weight: 400;
96
+ max-width: 600px;
97
+ margin: 0 auto;
98
+ line-height: 1.6;
99
+ }
100
+
101
+ /* === 状态指示器 === */
102
+ .status-bar {
103
+ display: flex;
104
+ align-items: center;
105
+ justify-content: center;
106
+ gap: 24px;
107
+ margin-bottom: 32px;
108
+ flex-wrap: wrap;
109
+ }
110
+
111
+ .status-item {
112
+ display: flex;
113
+ align-items: center;
114
+ gap: 8px;
115
+ background: white;
116
+ padding: 10px 20px;
117
+ border-radius: 16px;
118
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
119
+ font-size: 14px;
120
+ font-weight: 500;
121
+ color: #334155;
122
+ }
123
+
124
+ .status-dot {
125
+ width: 10px;
126
+ height: 10px;
127
+ border-radius: 50%;
128
+ background: #22c55e;
129
+ box-shadow: 0 0 8px rgba(34, 197, 94, 0.4);
130
+ animation: pulse-dot 2s infinite;
131
+ }
132
+
133
+ @keyframes pulse-dot {
134
+ 0%, 100% { opacity: 1; transform: scale(1); }
135
+ 50% { opacity: 0.6; transform: scale(0.85); }
136
+ }
137
+
138
+ .status-dot.warn {
139
+ background: #f59e0b;
140
+ box-shadow: 0 0 8px rgba(245, 158, 11, 0.4);
141
+ }
142
+
143
+ /* === 卡片容器 === */
144
+ .card {
145
+ background: white;
146
+ border-radius: 20px;
147
+ padding: 28px;
148
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
149
+ border: 1px solid rgba(0, 0, 0, 0.06);
150
+ transition: all 0.3s ease;
151
+ }
152
+
153
+ .card:hover {
154
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
155
+ transform: translateY(-1px);
156
+ }
157
+
158
+ .card-title {
159
+ font-size: 18px;
160
+ font-weight: 700;
161
+ color: #1e293b;
162
+ margin-bottom: 20px;
163
+ display: flex;
164
+ align-items: center;
165
+ gap: 10px;
166
+ }
167
+
168
+ .card-title .icon {
169
+ width: 36px;
170
+ height: 36px;
171
+ border-radius: 12px;
172
+ display: flex;
173
+ align-items: center;
174
+ justify-content: center;
175
+ font-size: 18px;
176
+ }
177
+
178
+ .icon-upload { background: linear-gradient(135deg, #dbeafe, #bfdbfe); }
179
+ .icon-sliders { background: linear-gradient(135deg, #fef3c7, #fde68a); }
180
+ .icon-result { background: linear-gradient(135deg, #dcfce7, #bbf7d0); }
181
+ .icon-stats { background: linear-gradient(135deg, #fce7f3, #fbcfe8); }
182
+
183
+ /* === 按钮样式 === */
184
+ .detect-btn {
185
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
186
+ border: none !important;
187
+ color: white !important;
188
+ font-weight: 700 !important;
189
+ font-size: 16px !important;
190
+ padding: 14px 32px !important;
191
+ border-radius: 14px !important;
192
+ box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4) !important;
193
+ transition: all 0.3s ease !important;
194
+ letter-spacing: 0.5px !important;
195
+ width: 100% !important;
196
+ }
197
+
198
+ .detect-btn:hover {
199
+ transform: translateY(-2px) !important;
200
+ box-shadow: 0 8px 25px rgba(102, 126, 234, 0.5) !important;
201
+ background: linear-gradient(135deg, #5a6fd6 0%, #6a3f9e 100%) !important;
202
+ }
203
+
204
+ .detect-btn:active {
205
+ transform: translateY(0) !important;
206
+ }
207
+
208
+ /* === 滑块样式 === */
209
+ input[type="range"] {
210
+ accent-color: #667eea !important;
211
+ height: 6px !important;
212
+ }
213
+
214
+ /* === 图片区域 === */
215
+ .image-container {
216
+ border-radius: 16px;
217
+ overflow: hidden;
218
+ border: 2px dashed #e2e8f0;
219
+ transition: all 0.3s ease;
220
+ background: #f8fafc;
221
+ }
222
+
223
+ .image-container:has(img) {
224
+ border: 2px solid #e2e8f0;
225
+ }
226
+
227
+ .image-container:hover {
228
+ border-color: #667eea;
229
+ }
230
+
231
+ /* === 检测摘要卡片 === */
232
+ .detection-summary {
233
+ background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
234
+ border-radius: 16px;
235
+ padding: 20px;
236
+ border: 1px solid #e2e8f0;
237
+ min-height: 80px;
238
+ }
239
+
240
+ .detection-item {
241
+ display: inline-flex;
242
+ align-items: center;
243
+ gap: 6px;
244
+ background: white;
245
+ padding: 6px 14px;
246
+ border-radius: 50px;
247
+ margin: 4px;
248
+ font-size: 13px;
249
+ font-weight: 500;
250
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
251
+ transition: all 0.2s ease;
252
+ }
253
+
254
+ .detection-item:hover {
255
+ transform: translateY(-1px);
256
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
257
+ }
258
+
259
+ .detection-count {
260
+ background: linear-gradient(135deg, #667eea, #764ba2);
261
+ color: white;
262
+ padding: 2px 10px;
263
+ border-radius: 50px;
264
+ font-weight: 700;
265
+ font-size: 12px;
266
+ }
267
+
268
+ .no-detection {
269
+ text-align: center;
270
+ color: #94a3b8;
271
+ font-size: 15px;
272
+ padding: 24px;
273
+ }
274
+
275
+ .no-detection .icon-large {
276
+ font-size: 48px;
277
+ display: block;
278
+ margin-bottom: 12px;
279
+ opacity: 0.5;
280
+ }
281
+
282
+ /* === 统计面板 === */
283
+ .stats-grid {
284
+ display: grid;
285
+ grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
286
+ gap: 12px;
287
+ }
288
+
289
+ .stat-box {
290
+ background: linear-gradient(135deg, #f8fafc, #f1f5f9);
291
+ border-radius: 14px;
292
+ padding: 16px;
293
+ text-align: center;
294
+ border: 1px solid #e2e8f0;
295
+ transition: all 0.2s ease;
296
+ }
297
+
298
+ .stat-box:hover {
299
+ background: linear-gradient(135deg, #eef2ff, #e0e7ff);
300
+ border-color: #c7d2fe;
301
+ }
302
+
303
+ .stat-number {
304
+ font-size: 32px;
305
+ font-weight: 800;
306
+ background: linear-gradient(135deg, #667eea, #764ba2);
307
+ -webkit-background-clip: text;
308
+ -webkit-text-fill-color: transparent;
309
+ background-clip: text;
310
+ }
311
+
312
+ .stat-label {
313
+ font-size: 12px;
314
+ color: #64748b;
315
+ font-weight: 500;
316
+ margin-top: 4px;
317
+ text-transform: uppercase;
318
+ letter-spacing: 0.5px;
319
+ }
320
+
321
+ /* === 置信度颜色 === */
322
+ .conf-high { color: #22c55e; font-weight: 700; }
323
+ .conf-medium { color: #f59e0b; font-weight: 700; }
324
+ .conf-low { color: #ef4444; font-weight: 700; }
325
+
326
+ /* === 页脚 === */
327
+ .footer {
328
+ text-align: center;
329
+ margin-top: 48px;
330
+ padding: 28px;
331
+ color: #94a3b8;
332
+ font-size: 13px;
333
+ border-top: 1px solid #e2e8f0;
334
+ }
335
+
336
+ .footer-links {
337
+ display: flex;
338
+ justify-content: center;
339
+ gap: 20px;
340
+ margin-bottom: 12px;
341
+ }
342
+
343
+ .footer-link {
344
+ color: #667eea;
345
+ text-decoration: none;
346
+ font-weight: 500;
347
+ transition: color 0.2s;
348
+ }
349
+
350
+ .footer-link:hover {
351
+ color: #764ba2;
352
+ }
353
+
354
+ /* === 响应式 === */
355
+ @media (max-width: 768px) {
356
+ .header-title { font-size: 28px; }
357
+ .stats-grid { grid-template-columns: repeat(2, 1fr); }
358
+ }
359
+ """
360
+
361
+ # ============================================================
362
+ # HTML 组件
363
+ # ============================================================
364
+ HEADER_HTML = """
365
+ <div class="header-section">
366
+ <div class="header-badge">🔬 Deep Learning · Object Detection</div>
367
+ <h1 class="header-title">智能目标检测系统</h1>
368
+ <p class="header-subtitle">
369
+ 基于 <strong>YOLOv8</strong> 深度学习模型,支持 80 类日常物体实时检测
370
+ —— 上传图片,即刻识别
371
+ </p>
372
+ <div class="status-bar">
373
+ <div class="status-item">
374
+ <span class="status-dot"></span>
375
+ <span>YOLOv8 Small · 已就绪</span>
376
+ </div>
377
+ <div class="status-item">
378
+ <span>🎯</span>
379
+ <span>80 类目标</span>
380
+ </div>
381
+ <div class="status-item">
382
+ <span>⚡</span>
383
+ <span>实时推理</span>
384
+ </div>
385
+ </div>
386
+ </div>
387
+ """
388
+
389
+ FOOTER_HTML = """
390
+ <div class="footer">
391
+ <div class="footer-links">
392
+ <a class="footer-link" href="https://docs.ultralytics.com/" target="_blank">📚 YOLOv8 文档</a>
393
+ <a class="footer-link" href="https://cocodataset.org/" target="_blank">📊 COCO 数据集</a>
394
+ <a class="footer-link" href="https://github.com/ultralytics/ultralytics" target="_blank">💻 GitHub</a>
395
+ </div>
396
+ <p>© 2026 深度学习期末综合设计 · Powered by PyTorch & Gradio</p>
397
+ </div>
398
+ """
399
+
400
+
401
+ # ============================================================
402
+ # 辅助函数
403
+ # ============================================================
404
+ def get_confidence_class(score):
405
+ """根据置信度返回 CSS 类名"""
406
+ if score >= 0.7:
407
+ return "conf-high"
408
+ elif score >= 0.4:
409
+ return "conf-medium"
410
+ else:
411
+ return "conf-low"
412
+
413
+
414
+ def build_summary_html(detections, class_names_en, class_names_zh):
415
+ """构建检测摘要 HTML"""
416
+ if not detections:
417
+ return """
418
+ <div class="no-detection">
419
+ <span class="icon-large">🔍</span>
420
+ <p>未检测到任何目标</p>
421
+ <p style="font-size:13px;color:#94a3b8;">尝试降低置信度阈值或上传其他图片</p>
422
+ </div>
423
+ """
424
+
425
+ # 统计信息
426
+ class_counts = {}
427
+ for det in detections:
428
+ cls_id = int(det[5])
429
+ en_name = class_names_en.get(cls_id, f"cls_{cls_id}")
430
+ zh_name = class_names_zh.get(cls_id, f"未知")
431
+ key = (en_name, zh_name)
432
+ class_counts[key] = class_counts.get(key, 0) + 1
433
+
434
+ total = len(detections)
435
+ unique_classes = len(class_counts)
436
+
437
+ # 构建 HTML
438
+ html = '<div style="margin-bottom:20px;">'
439
+
440
+ # 统计面板
441
+ html += '<div class="stats-grid" style="margin-bottom:20px;">'
442
+ html += f'<div class="stat-box"><div class="stat-number">{total}</div><div class="stat-label">检测目标</div></div>'
443
+ html += f'<div class="stat-box"><div class="stat-number">{unique_classes}</div><div class="stat-label">类别数</div></div>'
444
+ top_score = max(d[4] for d in detections) if detections else 0
445
+ html += f'<div class="stat-box"><div class="stat-number">{top_score:.0%}</div><div class="stat-label">最高置信度</div></div>'
446
+ avg_score = sum(d[4] for d in detections) / total if total > 0 else 0
447
+ html += f'<div class="stat-box"><div class="stat-number">{avg_score:.0%}</div><div class="stat-label">平均置信度</div></div>'
448
+ html += '</div>'
449
+
450
+ # 检测列表
451
+ html += '<div style="font-size:14px;font-weight:600;color:#475569;margin-bottom:10px;">📋 检测详情</div>'
452
+ html += '<div style="display:flex;flex-wrap:wrap;gap:6px;">'
453
+ for (en_name, zh_name), count in sorted(class_counts.items(), key=lambda x: -x[1]):
454
+ html += (
455
+ f'<div class="detection-item">'
456
+ f'<span>{zh_name}</span>'
457
+ f'<span style="color:#94a3b8;font-size:11px;">{en_name}</span>'
458
+ f'<span class="detection-count">×{count}</span>'
459
+ f'</div>'
460
+ )
461
+ html += '</div>'
462
+
463
+ # 置信度分布
464
+ html += '<div style="margin-top:16px;font-size:12px;color:#94a3b8;">'
465
+ high_conf = sum(1 for d in detections if d[4] >= 0.7)
466
+ mid_conf = sum(1 for d in detections if 0.4 <= d[4] < 0.7)
467
+ low_conf = sum(1 for d in detections if d[4] < 0.4)
468
+ html += f'🟢 高置信度 (≥70%): {high_conf} 个 · '
469
+ html += f'🟡 中置信度 (40-70%): {mid_conf} 个 · '
470
+ html += f'🔴 低置信度 (&lt;40%): {low_conf} 个'
471
+ html += '</div>'
472
+
473
+ html += '</div>'
474
+
475
+ return html
476
+
477
+
478
+ def build_empty_summary_html():
479
+ """空检测的 HTML 占位"""
480
+ return """
481
+ <div class="no-detection">
482
+ <span class="icon-large">📸</span>
483
+ <p>等待上传图片...</p>
484
+ <p style="font-size:13px;color:#94a3b8;">上传图片后自动检测,或点击"开始检测"</p>
485
+ </div>
486
+ """
487
+
488
+
489
+ # ============================================================
490
+ # 检测核心函数
491
+ # ============================================================
492
+ def detect_and_render(image, conf_thres=0.25, iou_thres=0.45):
493
+ """检测 + 渲染可视化结果"""
494
+ if image is None:
495
+ return None, build_empty_summary_html()
496
+
497
+ # BGR 转换
498
+ image_bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
499
+
500
+ # 检测
501
+ detections = detector.detect(image_bgr, conf_thres=conf_thres, iou_thres=iou_thres)
502
+
503
+ # 绘制边界框
504
+ annotated = draw_boxes(image, detections, detector.class_names, detector.colors)
505
+
506
+ # 构建 HTML 摘要
507
+ summary_html = build_summary_html(
508
+ detections,
509
+ detector.class_names_en,
510
+ detector.class_names_zh
511
+ )
512
+
513
+ return annotated, summary_html
514
+
515
+
516
+ # ============================================================
517
+ # 构建界面
518
+ # ============================================================
519
+ def create_ui():
520
+ with gr.Blocks(title="智能目标检测系统 · YOLOv8") as demo:
521
+
522
+ # 顶部 Header
523
+ gr.HTML(HEADER_HTML)
524
+
525
+ # 主体布局
526
+ with gr.Row(equal_height=True):
527
+ # === 左侧:上传区域 ===
528
+ with gr.Column(scale=5, min_width=320):
529
+ with gr.Group(elem_classes="card"):
530
+ gr.HTML('<div class="card-title"><span class="icon icon-upload">📤</span> 上传图片</div>')
531
+
532
+ input_image = gr.Image(
533
+ label=None,
534
+ type="numpy",
535
+ sources=["upload", "clipboard", "webcam"],
536
+ height=420,
537
+ elem_classes="image-container",
538
+ show_label=False,
539
+ )
540
+
541
+ with gr.Row():
542
+ conf_slider = gr.Slider(
543
+ minimum=0.05,
544
+ maximum=0.95,
545
+ value=0.25,
546
+ step=0.05,
547
+ label="🎯 置信度阈值",
548
+ info="值越低检测越多(可能误检),越高越精准",
549
+ )
550
+ iou_slider = gr.Slider(
551
+ minimum=0.1,
552
+ maximum=0.9,
553
+ value=0.45,
554
+ step=0.05,
555
+ label="📐 IoU 阈值",
556
+ info="重叠框过滤强度,越低去重越强",
557
+ )
558
+
559
+ detect_btn = gr.Button(
560
+ "🚀 开始检测",
561
+ variant="primary",
562
+ size="lg",
563
+ elem_classes="detect-btn",
564
+ )
565
+
566
+ # === 右侧:结果区域 ===
567
+ with gr.Column(scale=5, min_width=320):
568
+ with gr.Group(elem_classes="card"):
569
+ gr.HTML('<div class="card-title"><span class="icon icon-result">📊</span> 检测结果</div>')
570
+ output_image = gr.Image(
571
+ label=None,
572
+ type="numpy",
573
+ height=420,
574
+ elem_classes="image-container",
575
+ show_label=False,
576
+ )
577
+
578
+ with gr.Group(elem_classes="card"):
579
+ gr.HTML('<div class="card-title"><span class="icon icon-stats">📋</span> 检测摘要</div>')
580
+ output_summary = gr.HTML(
581
+ value=build_empty_summary_html(),
582
+ elem_classes="detection-summary",
583
+ )
584
+
585
+ # 示例图片
586
+ with gr.Accordion("📸 示例图片(点击加载测试)", open=False):
587
+ gr.Examples(
588
+ examples=[
589
+ ["https://ultralytics.com/images/bus.jpg", 0.25, 0.45],
590
+ ["https://ultralytics.com/images/zidane.jpg", 0.25, 0.45],
591
+ ],
592
+ inputs=[input_image, conf_slider, iou_slider],
593
+ label=None,
594
+ )
595
+
596
+ # 页脚
597
+ gr.HTML(FOOTER_HTML)
598
+
599
+ # === 事件绑定 ===
600
+ detect_btn.click(
601
+ fn=detect_and_render,
602
+ inputs=[input_image, conf_slider, iou_slider],
603
+ outputs=[output_image, output_summary],
604
+ )
605
+
606
+ input_image.change(
607
+ fn=detect_and_render,
608
+ inputs=[input_image, conf_slider, iou_slider],
609
+ outputs=[output_image, output_summary],
610
+ )
611
+
612
+ return demo
613
+
614
+
615
+ # ============================================================
616
+ # 启动
617
+ # ============================================================
618
+ if __name__ == '__main__':
619
+ demo = create_ui()
620
+ demo.queue(max_size=20, default_concurrency_limit=5)
621
+ demo.launch(
622
+ server_name="0.0.0.0",
623
+ server_port=7860,
624
+ share=False,
625
+ show_error=True,
626
+ css=CUSTOM_CSS,
627
+ head="""
628
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
629
+ <meta name="description" content="基于 YOLOv8 的智能目标检测系统">
630
+ """,
631
+ favicon_path=None,
632
+ )
detector.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 目标检测器模块
3
+ 基于 ultralytics YOLOv8 预训练模型 (COCO 80类)
4
+ """
5
+
6
+ import numpy as np
7
+ import cv2
8
+ from ultralytics import YOLO
9
+ import os
10
+
11
+
12
+ # COCO 数据集 80 类中文名称映射
13
+ COCO_CLASSES_ZH = {
14
+ 0: '人', 1: '自行车', 2: '汽车', 3: '摩托车', 4: '飞机',
15
+ 5: '公交车', 6: '火车', 7: '卡车', 8: '船', 9: '红绿灯',
16
+ 10: '消火栓', 11: '停车标志', 12: '停车计时器', 13: '长椅', 14: '鸟',
17
+ 15: '猫', 16: '狗', 17: '马', 18: '羊', 19: '牛',
18
+ 20: '大象', 21: '熊', 22: '斑马', 23: '长颈鹿', 24: '背包',
19
+ 25: '雨伞', 26: '手提包', 27: '领带', 28: '行李箱', 29: '飞盘',
20
+ 30: '滑雪板', 31: '雪板', 32: '球', 33: '风筝', 34: '棒球棒',
21
+ 35: '手套', 36: '滑板', 37: '冲浪板', 38: '网球拍', 39: '瓶子',
22
+ 40: '酒杯', 41: '杯子', 42: '叉子', 43: '刀', 44: '勺子',
23
+ 45: '碗', 46: '香蕉', 47: '苹果', 48: '三明治', 49: '橙子',
24
+ 50: '西兰花', 51: '胡萝卜', 52: '热狗', 53: '披萨', 54: '甜甜圈',
25
+ 55: '蛋糕', 56: '椅子', 57: '沙发', 58: '盆栽', 59: '床',
26
+ 60: '餐桌', 61: '马桶', 62: '显示器', 63: '笔记本', 64: '鼠标',
27
+ 65: '遥控器', 66: '键盘', 67: '手机', 68: '微波炉', 69: '烤箱',
28
+ 70: '烤面包机', 71: '水槽', 72: '冰箱', 73: '书', 74: '钟',
29
+ 75: '花瓶', 76: '剪刀', 77: '泰迪熊', 78: '吹风机', 79: '牙刷'
30
+ }
31
+
32
+ COCO_CLASSES_EN = {
33
+ 0: 'person', 1: 'bicycle', 2: 'car', 3: 'motorcycle', 4: 'airplane',
34
+ 5: 'bus', 6: 'train', 7: 'truck', 8: 'boat', 9: 'traffic light',
35
+ 10: 'fire hydrant', 11: 'stop sign', 12: 'parking meter', 13: 'bench', 14: 'bird',
36
+ 15: 'cat', 16: 'dog', 17: 'horse', 18: 'sheep', 19: 'cow',
37
+ 20: 'elephant', 21: 'bear', 22: 'zebra', 23: 'giraffe', 24: 'backpack',
38
+ 25: 'umbrella', 26: 'handbag', 27: 'tie', 28: 'suitcase', 29: 'frisbee',
39
+ 30: 'skis', 31: 'snowboard', 32: 'sports ball', 33: 'kite', 34: 'baseball bat',
40
+ 35: 'baseball glove', 36: 'skateboard', 37: 'surfboard', 38: 'tennis racket', 39: 'bottle',
41
+ 40: 'wine glass', 41: 'cup', 42: 'fork', 43: 'knife', 44: 'spoon',
42
+ 45: 'bowl', 46: 'banana', 47: 'apple', 48: 'sandwich', 49: 'orange',
43
+ 50: 'broccoli', 51: 'carrot', 52: 'hot dog', 53: 'pizza', 54: 'donut',
44
+ 55: 'cake', 56: 'chair', 57: 'couch', 58: 'potted plant', 59: 'bed',
45
+ 60: 'dining table', 61: 'toilet', 62: 'tv', 63: 'laptop', 64: 'mouse',
46
+ 65: 'remote', 66: 'keyboard', 67: 'cell phone', 68: 'microwave', 69: 'oven',
47
+ 70: 'toaster', 71: 'sink', 72: 'refrigerator', 73: 'book', 74: 'clock',
48
+ 75: 'vase', 76: 'scissors', 77: 'teddy bear', 78: 'hair drier', 79: 'toothbrush'
49
+ }
50
+
51
+
52
+ class Detector:
53
+ """YOLOv8 目标检测器(使用 ultralytics 预训练模型)"""
54
+
55
+ def __init__(self, model_name='yolov8n.pt', device=None):
56
+ """
57
+ 初始化检测器
58
+
59
+ Args:
60
+ model_name: 模型名称或路径
61
+ - 'yolov8n.pt' Nano (最快, ~6MB)
62
+ - 'yolov8s.pt' Small (~22MB)
63
+ - 'yolov8m.pt' Medium (~52MB)
64
+ device: 推理设备 ('cuda', 'cpu', 或 None 自动选择)
65
+ """
66
+ self.device = device or ('cuda' if cv2.cuda.getCudaEnabledDeviceCount() > 0 else 'cpu')
67
+ print(f"[INFO] 使用设备: {self.device}")
68
+
69
+ # 加载模型
70
+ # 优先使用本地模型文件,否则尝试从镜像下载
71
+ local_model = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'models', model_name)
72
+ if os.path.exists(local_model):
73
+ model_name = local_model
74
+ elif os.path.exists(model_name):
75
+ pass # 使用用户指定的路径
76
+ else:
77
+ # 尝试从 Hugging Face 镜像下载
78
+ print(f"[INFO] 本地模型不存在,尝试从镜像下载: {model_name}")
79
+ try:
80
+ os.environ.setdefault('HF_ENDPOINT', 'https://hf-mirror.com')
81
+ from huggingface_hub import hf_hub_download
82
+ model_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'models')
83
+ os.makedirs(model_dir, exist_ok=True)
84
+ downloaded = hf_hub_download(
85
+ repo_id='Ultralytics/YOLOv8',
86
+ filename=model_name,
87
+ local_dir=model_dir,
88
+ )
89
+ model_name = downloaded
90
+ print(f"[INFO] 模型下载成功: {model_name}")
91
+ except Exception as e:
92
+ print(f"[WARN] 镜像下载失败: {e},将尝试 ultralytics 默认下载")
93
+
94
+ self.model_path = model_name
95
+ self.model = YOLO(model_name)
96
+
97
+ # 类别信息
98
+ self.class_names_en = COCO_CLASSES_EN
99
+ self.class_names_zh = COCO_CLASSES_ZH
100
+ self.class_names = [f"{COCO_CLASSES_EN[i]} ({COCO_CLASSES_ZH[i]})" for i in range(80)]
101
+ self.num_classes = 80
102
+ self.class_names_en_only = [COCO_CLASSES_EN[i] for i in range(80)]
103
+
104
+ # 为每个类别生��颜色
105
+ np.random.seed(42)
106
+ self.colors = np.random.randint(50, 255, size=(self.num_classes, 3), dtype=np.uint8).tolist()
107
+
108
+ print(f"[INFO] 模型加载完成: {model_name}")
109
+ print(f"[INFO] 类别数量: {self.num_classes} (COCO)")
110
+
111
+ def detect(self, image, conf_thres=0.25, iou_thres=0.45, max_det=100):
112
+ """
113
+ 对图片进行目标检测
114
+
115
+ Args:
116
+ image: numpy array (H, W, 3) BGR 图片
117
+ conf_thres: 置信度阈值
118
+ iou_thres: NMS IoU 阈值
119
+ max_det: 最大检测数量
120
+
121
+ Returns:
122
+ detections: list of [x1, y1, x2, y2, score, class_id]
123
+ """
124
+ # 转换为 RGB(ultralytics 需要 RGB)
125
+ image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
126
+
127
+ # 运行检测
128
+ results = self.model(
129
+ image_rgb,
130
+ conf=conf_thres,
131
+ iou=iou_thres,
132
+ max_det=max_det,
133
+ verbose=False,
134
+ )
135
+
136
+ # 解析结果
137
+ detections = []
138
+ if results and len(results) > 0:
139
+ result = results[0]
140
+ if result.boxes is not None and len(result.boxes) > 0:
141
+ boxes = result.boxes.xyxy.cpu().numpy() # [N, 4] x1,y1,x2,y2
142
+ scores = result.boxes.conf.cpu().numpy() # [N]
143
+ class_ids = result.boxes.cls.cpu().numpy().astype(int) # [N]
144
+
145
+ for box, score, cls_id in zip(boxes, scores, class_ids):
146
+ x1, y1, x2, y2 = box.tolist()
147
+ detections.append([x1, y1, x2, y2, float(score), int(cls_id)])
148
+
149
+ # 按置信度降序
150
+ detections.sort(key=lambda x: x[4], reverse=True)
151
+
152
+ return detections
153
+
154
+ def get_class_name(self, class_id, lang='zh'):
155
+ """获取类别名称"""
156
+ if lang == 'zh':
157
+ return self.class_names_zh.get(class_id, f"未知_{class_id}")
158
+ return self.class_names_en.get(class_id, f"unknown_{class_id}")
159
+
160
+ def get_color(self, class_id):
161
+ """获取类别对应颜色"""
162
+ if 0 <= class_id < len(self.colors):
163
+ return tuple(self.colors[class_id])
164
+ return (255, 255, 255)
visualize.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 检测结果可视化模块
3
+ 在图片上绘制检测框、类别标签和置信度
4
+ """
5
+
6
+ import cv2
7
+ import numpy as np
8
+ from PIL import Image, ImageDraw, ImageFont
9
+ import matplotlib.pyplot as plt
10
+ from io import BytesIO
11
+
12
+
13
+ def draw_detections_cv2(image, detections, class_names, colors):
14
+ """
15
+ 使用 OpenCV 绘制检测结果(适合中文环境用 PIL 方案更好)
16
+
17
+ Args:
18
+ image: numpy array (H, W, 3) BGR
19
+ detections: list of [x1, y1, x2, y2, score, class_id]
20
+ class_names: list of class name strings
21
+ colors: list of (R, G, B) tuples
22
+
23
+ Returns:
24
+ image with boxes drawn (BGR)
25
+ """
26
+ result = image.copy()
27
+
28
+ for det in detections:
29
+ x1, y1, x2, y2, score, cls_id = det
30
+ x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2)
31
+ cls_id = int(cls_id)
32
+
33
+ color = tuple(colors[cls_id]) if cls_id < len(colors) else (0, 255, 0)
34
+ class_name = class_names[cls_id] if cls_id < len(class_names) else f"cls_{cls_id}"
35
+ label = f"{class_name}: {score:.2f}"
36
+
37
+ # 绘制边界框
38
+ cv2.rectangle(result, (x1, y1), (x2, y2), color, 2)
39
+
40
+ # 绘制标签背景
41
+ (label_w, label_h), baseline = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 2)
42
+ cv2.rectangle(result, (x1, y1 - label_h - 10), (x1 + label_w, y1), color, -1)
43
+
44
+ # 绘制标签文字
45
+ cv2.putText(result, label, (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 2)
46
+
47
+ return result
48
+
49
+
50
+ def draw_detections_pil(image, detections, class_names, colors, font_path=None):
51
+ """
52
+ 使用 PIL 绘制检测结果(支持中文,效果更好)
53
+
54
+ Args:
55
+ image: numpy array (H, W, 3) RGB
56
+ detections: list of [x1, y1, x2, y2, score, class_id]
57
+ class_names: list of class name strings
58
+ colors: list of (R, G, B) tuples
59
+ font_path: 中文字体路径(可选)
60
+
61
+ Returns:
62
+ image with boxes drawn (RGB, numpy array)
63
+ """
64
+ result = Image.fromarray(image.astype(np.uint8))
65
+ draw = ImageDraw.Draw(result)
66
+
67
+ # 尝试加载字体
68
+ try:
69
+ if font_path:
70
+ font = ImageFont.truetype(font_path, 16)
71
+ small_font = ImageFont.truetype(font_path, 12)
72
+ else:
73
+ # 尝试系统默认字体
74
+ font = ImageFont.truetype("arial.ttf", 16)
75
+ small_font = ImageFont.truetype("arial.ttf", 12)
76
+ except Exception:
77
+ font = ImageFont.load_default()
78
+ small_font = ImageFont.load_default()
79
+
80
+ for det in detections:
81
+ x1, y1, x2, y2, score, cls_id = det
82
+ cls_id = int(cls_id)
83
+
84
+ color = tuple(colors[cls_id]) if cls_id < len(colors) else (0, 255, 0)
85
+ class_name = class_names[cls_id] if cls_id < len(class_names) else f"cls_{cls_id}"
86
+ label = f"{class_name}: {score:.2f}"
87
+
88
+ # 绘制边界框(加粗)
89
+ for offset in range(2):
90
+ draw.rectangle(
91
+ [x1 - offset, y1 - offset, x2 + offset, y2 + offset],
92
+ outline=color,
93
+ width=1
94
+ )
95
+
96
+ # 绘制标签背景和文字
97
+ try:
98
+ text_bbox = draw.textbbox((0, 0), label, font=small_font)
99
+ text_w = text_bbox[2] - text_bbox[0]
100
+ text_h = text_bbox[3] - text_bbox[1]
101
+ except Exception:
102
+ text_w, text_h = 60, 14
103
+
104
+ label_y = max(0, y1 - text_h - 6)
105
+ draw.rectangle([x1, label_y, x1 + text_w + 4, label_y + text_h + 4], fill=color)
106
+ draw.text((x1 + 2, label_y + 2), label, fill=(255, 255, 255), font=small_font)
107
+
108
+ return np.array(result)
109
+
110
+
111
+ def draw_detections_pil_enhanced(image, detections, class_names, colors):
112
+ """
113
+ 增强版 PIL 绘制,支持阴影、圆角、半透明等美化效果
114
+
115
+ Args:
116
+ image: numpy array (H, W, 3) RGB
117
+ detections: list of [x1, y1, x2, y2, score, class_id]
118
+ class_names: list of class name strings
119
+ colors: list of (R, G, B) tuples
120
+
121
+ Returns:
122
+ image with beautified boxes drawn (RGB, numpy array)
123
+ """
124
+ from PIL import ImageDraw
125
+
126
+ h, w = image.shape[:2]
127
+ result = Image.fromarray(image.astype(np.uint8))
128
+ overlay = Image.new('RGBA', (w, h), (0, 0, 0, 0))
129
+ overlay_draw = ImageDraw.Draw(overlay)
130
+ draw = ImageDraw.Draw(result)
131
+
132
+ # 尝试加载字体
133
+ try:
134
+ # Windows 中文字体
135
+ for font_path in [
136
+ "C:/Windows/Fonts/msyh.ttc",
137
+ "C:/Windows/Fonts/simhei.ttf",
138
+ "C:/Windows/Fonts/msyhbd.ttc",
139
+ "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
140
+ ]:
141
+ try:
142
+ font = ImageFont.truetype(font_path, 16)
143
+ small_font = ImageFont.truetype(font_path, 12)
144
+ break
145
+ except Exception:
146
+ continue
147
+ else:
148
+ font = ImageFont.load_default()
149
+ small_font = ImageFont.load_default()
150
+ except Exception:
151
+ font = ImageFont.load_default()
152
+ small_font = ImageFont.load_default()
153
+
154
+ # 按置信度降序绘制(高置信度在上层)
155
+ sorted_dets = sorted(detections, key=lambda x: x[4])
156
+
157
+ for det in sorted_dets:
158
+ x1, y1, x2, y2, score, cls_id = det
159
+ x1, y1 = max(0, int(x1)), max(0, int(y1))
160
+ x2, y2 = min(w, int(x2)), min(h, int(y2))
161
+ cls_id = int(cls_id)
162
+ if x2 <= x1 or y2 <= y1:
163
+ continue
164
+
165
+ color = tuple(colors[cls_id]) if cls_id < len(colors) else (0, 255, 0)
166
+ class_name = class_names[cls_id] if cls_id < len(class_names) else f"cls_{cls_id}"
167
+
168
+ # 根据置信度调整线宽
169
+ if score >= 0.7:
170
+ line_width = 4
171
+ shadow_offset = 3
172
+ elif score >= 0.4:
173
+ line_width = 3
174
+ shadow_offset = 2
175
+ else:
176
+ line_width = 2
177
+ shadow_offset = 1
178
+
179
+ # === 绘制阴影 ===
180
+ shadow_color = (0, 0, 0, 40)
181
+ for dx in range(shadow_offset):
182
+ overlay_draw.rectangle(
183
+ [x1 + shadow_offset, y1 + shadow_offset, x2 + shadow_offset, y2 + shadow_offset],
184
+ outline=shadow_color,
185
+ width=line_width + 2,
186
+ )
187
+
188
+ # === 绘制半透明填充 ===
189
+ fill_alpha = 25
190
+ if score >= 0.7:
191
+ fill_alpha = 35
192
+ fill_rgba = color + (fill_alpha,)
193
+ overlay_draw.rectangle([x1, y1, x2, y2], fill=fill_rgba)
194
+
195
+ # === 绘制主边界框 ===
196
+ # 外层(深色边缘)
197
+ dark_color = tuple(max(0, c - 40) for c in color)
198
+ overlay_draw.rectangle([x1, y1, x2, y2], outline=dark_color + (220,), width=line_width + 2)
199
+ # 内层(亮色)
200
+ overlay_draw.rectangle([x1, y1, x2, y2], outline=color + (255,), width=line_width)
201
+
202
+ # === 绘制角标(四角加粗) ===
203
+ corner_len = min(15, (x2 - x1) // 4, (y2 - y1) // 4)
204
+ for cx, cy, dx, dy in [
205
+ (x1, y1, 1, 1), (x2, y1, -1, 1),
206
+ (x1, y2, 1, -1), (x2, y2, -1, -1),
207
+ ]:
208
+ overlay_draw.line(
209
+ [(cx, cy), (cx + corner_len * dx, cy), (cx, cy), (cx, cy + corner_len * dy)],
210
+ fill=color + (230,),
211
+ width=line_width + 1,
212
+ )
213
+
214
+ # === 绘制标签 ===
215
+ label_text = f"{class_name} {score:.0%}"
216
+ try:
217
+ text_bbox = draw.textbbox((0, 0), label_text, font=small_font)
218
+ text_w = text_bbox[2] - text_bbox[0]
219
+ text_h = text_bbox[3] - text_bbox[1]
220
+ except Exception:
221
+ text_w, text_h = len(label_text) * 7, 14
222
+
223
+ padding = 6
224
+ label_bg_w = text_w + padding * 2
225
+ label_bg_h = text_h + padding
226
+
227
+ # 标签位置(优先放框内顶部,框太小就放框外)
228
+ if y1 > label_bg_h + 8:
229
+ label_y = y1 - label_bg_h - 2
230
+ label_bg_y1 = label_y
231
+ label_bg_y2 = y1
232
+ else:
233
+ label_y = y1 + 2
234
+ label_bg_y1 = y1
235
+ label_bg_y2 = y1 + label_bg_h
236
+
237
+ label_x = x1 + 2
238
+
239
+ # 标签背景(扩展到框外)
240
+ overlay_draw.rectangle(
241
+ [label_x, label_bg_y1, label_x + label_bg_w, label_bg_y2],
242
+ fill=color + (200,),
243
+ )
244
+
245
+ # 标签文字
246
+ draw.text(
247
+ (label_x + padding, label_y + padding // 2),
248
+ label_text,
249
+ fill=(255, 255, 255),
250
+ font=small_font,
251
+ )
252
+
253
+ # 合成
254
+ result = Image.alpha_composite(result.convert('RGBA'), overlay)
255
+ return np.array(result.convert('RGB'))
256
+
257
+
258
+ def draw_detections(image, detections, class_names, colors):
259
+ """
260
+ 综合绘制检测结果
261
+ """
262
+ try:
263
+ return draw_detections_pil_enhanced(image, detections, class_names, colors)
264
+ except Exception:
265
+ return draw_detections_pil(image, detections, class_names, colors)
266
+
267
+
268
+ def create_summary_text(detections, class_names):
269
+ """
270
+ 生成检测结果摘要文本
271
+
272
+ Args:
273
+ detections: list of [x1, y1, x2, y2, score, class_id]
274
+ class_names: list of class name strings
275
+
276
+ Returns:
277
+ summary string
278
+ """
279
+ if not detections:
280
+ return "未检测到任何目标。"
281
+
282
+ # 统计各类别数量
283
+ class_counts = {}
284
+ for det in detections:
285
+ cls_id = int(det[5])
286
+ class_name = class_names[cls_id] if cls_id < len(class_names) else f"未知_{cls_id}"
287
+ class_counts[class_name] = class_counts.get(class_name, 0) + 1
288
+
289
+ lines = [f"共检测到 {len(detections)} 个目标:"]
290
+ for name, count in class_counts.items():
291
+ lines.append(f" • {name}: {count} 个")
292
+
293
+ return "\n".join(lines)