-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.py
More file actions
932 lines (867 loc) · 55.8 KB
/
app.py
File metadata and controls
932 lines (867 loc) · 55.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
# ====================== 依赖导入 ======================
import streamlit as st
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import folium
from streamlit_folium import st_folium
from folium.plugins import HeatMap, AntPath, Fullscreen, MarkerCluster, MeasureControl
import io
from docx import Document
from datetime import datetime
import requests
import json
import math
import hashlib
import traceback
import numpy as np
from PIL import Image
import time
import base64
import uuid
from typing import Dict, List, Any, Tuple, Optional
# ====================== 全局配置 ======================
st.set_page_config(page_title="智水溯源 - 专业版 V8.2", layout="wide", page_icon="🧪")
# 自定义样式(强化专业卡片展示)
def local_css():
st.markdown("""
<style>
.main { background-color: #f8fafc; }
.stMetric { background-color: #ffffff; padding: 20px; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-top: 4px solid #3b82f6; }
[data-testid="stSidebar"] { background-color: #0f172a; }
[data-testid="stSidebar"] .stMarkdown, [data-testid="stSidebar"] label { color: #e2e8f0; }
.report-card { padding: 25px; border-radius: 15px; background: #ffffff; border: 1px solid #e2e8f0; height: 100%; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); }
.history-card { background: #f1f5f9; border-radius: 10px; padding: 10px; margin-bottom: 10px; cursor: pointer; }
.evidence-card { background: #f0f9ff; border-left: 4px solid #3b82f6; padding: 15px; border-radius: 8px; margin: 10px 0; }
.correction-card { background: #fffbeb; border-left: 4px solid #f59e0b; padding: 15px; border-radius: 8px; margin: 10px 0; }
.risk-card-red { background: #fef2f2; border-left: 4px solid #dc2626; padding: 15px; border-radius: 8px; margin: 10px 0; }
.stAlert > div { padding: 10px 15px; }
table { width: 100%; border-collapse: collapse; margin: 15px 0; }
table th { background-color: #f1f5f9; padding: 10px; text-align: left; border: 1px solid #e2e8f0; font-weight: 600; }
table td { padding: 10px; border: 1px solid #e2e8f0; }
</style>
""", unsafe_allow_html=True)
local_css()
# ====================== 工具函数:省市名称归一化 ======================
def normalize_region_name(full_name: str, is_province: bool = True) -> str:
if not full_name:
return ""
full_name = full_name.strip()
if is_province:
for suffix in ["省", "市", "自治区"]:
if full_name.endswith(suffix):
return full_name[:-len(suffix)]
return full_name
else:
for suffix in ["市", "县", "区", "自治州"]:
if full_name.endswith(suffix):
return full_name[:-len(suffix)]
return full_name
# ====================== 静态数据预加载 ======================
@st.cache_resource(ttl=86400, show_spinner=False)
def load_static_database():
HOT_EVENTS = {
"湖北孝感府河黑臭水体污染事件": {
"center": [30.92, 113.92], "zoom": 12,
"metrics": {"超标因子": "COD、氨氮、总磷、挥发酚", "预警等级": "红色", "影响区域": "孝感城区府河流域", "污染类型": "黑臭水体"},
"pollution_sources": [{"lat": 30.92, "lon": 113.92, "intensity": 0.95, "main_pollutant": "COD、木质素、挥发酚"}],
"pollutant_composition": {"COD": 45, "氨氮": 30, "总磷": 15, "挥发酚": 5, "其他": 5},
"analysis": {
"root_cause_pro": "府河上游造纸、印染类企业废水超标排放,叠加城镇雨污混排管网溢流,导致水体溶解氧耗尽,厌氧发酵形成黑臭水体,直接影响下游水产养殖与农田灌溉安全。",
"root_cause_plain": "上游造纸厂、印染厂排的脏水,加上城镇生活污水混着雨水排进河里,水里氧气耗光了,水发黑发臭,没法浇地、养鱼。",
"solution_pro": "全面排查上游涉水企业排污口,取缔非法排污;开展河道清淤、曝气增氧与生态修复;实施雨污分流管网改造,建设截污工程。",
"solution_plain": "不要用这个河水浇地、养鱼,别碰脏水,戴口罩防臭味,赶紧给环保部门打电话举报排污线索。"
}
},
"河北保定蠡县红色地下水污染事件": {
"center": [38.49, 115.58], "zoom": 12,
"metrics": {"超标因子": "六价铬、总铬、硫化物、COD、重金属", "预警等级": "红色", "影响区域": "蠡县周边村镇农田与饮用水源", "污染类型": "地下水污染"},
"pollution_sources": [{"lat": 38.49, "lon": 115.58, "intensity": 0.98, "main_pollutant": "铬、硫化物、鞣制染料"}],
"pollutant_composition": {"重金属铬": 40, "硫化物": 25, "COD": 20, "氨氮": 10, "其他": 5},
"analysis": {
"root_cause_pro": "蠡县皮毛、制革企业非法倾倒未处理的鞣制废水,通过渗坑、渗井渗入浅层地下水,高浓度含铬、含硫废水导致地下水显色异常、污染物超标,长期灌溉造成土壤重金属累积,威胁粮食安全与饮用水安全。",
"root_cause_plain": "皮毛加工厂把没处理的红棕色脏水偷偷倒在土坑里,渗到地下水里,井水变成红色,浇地会让地里也染上重金属,种出来的粮食不敢吃,喝了会生病。",
"solution_pro": "立即封堵非法渗坑,开展地下水抽提与原位修复;全面关停非法排污皮毛加工窝点;开展区域地下水、农田土壤全面监测;建设集中式污水处理设施,规范行业排污行为。",
"solution_plain": "绝对不能喝这个井水,也不能用它浇口粮地,喝水用桶装水/自来水,赶紧留水样举报,等环保部门来检测处理。"
}
},
"长江中下游某支流异常显色事件": {
"center": [30.5, 114.3], "zoom": 11,
"metrics": {"超标因子": "锑、COD", "预警等级": "红色", "影响人口": "12.5万人", "响应时长": "4h"},
"pollution_sources": [
{"name": "化工厂A", "lat": 30.55, "lon": 114.25, "intensity": 0.85, "main_pollutant": "苯系物"},
{"name": "工业园B", "lat": 30.48, "lon": 114.35, "intensity": 0.65, "main_pollutant": "高色度有机废水"},
],
"monitoring_points": [
{"name": "上游对照", "lat": 30.65, "lon": 114.15, "cod": 12},
{"name": "污染混合区", "lat": 30.53, "lon": 114.28, "cod": 85},
],
"path": [[30.58, 114.42], [30.52, 114.30], [30.45, 114.15]],
"pollutant_composition": {"工业有机物": 45, "重金属": 15, "农业氮磷": 30, "其他": 10},
"analysis": {
"root_cause_pro": "短时强降雨触发溢流污染,工业园区初期雨水直排,水环境容量不足。",
"root_cause_plain": "大雨导致工厂脏水溢流进河,河里水少化不开。",
"solution_pro": "建设分流制管网;生态补水;湿地拦截。",
"solution_plain": "修雨水管;放清水冲洗;河边种草。"
}
}
}
INDUSTRIAL_DATA = [
# 河北保定蠡县区域
{"name": "蠡县XX皮毛加工有限公司", "province": "河北", "city": "保定", "type": "皮毛制革", "lat": 38.49, "lon": 115.58, "pollutants": ["COD", "氨氮", "总铬", "六价铬", "硫化物", "有机物", "重金属", "鞣制染料"], "risk": "极高", "credit_id": "91130600MA"},
{"name": "蠡县XX制革有限公司", "province": "河北", "city": "保定", "type": "皮毛制革", "lat": 38.51, "lon": 115.56, "pollutants": ["硫化物", "COD", "总铬", "氨氮", "挥发酚", "悬浮物", "鞣制剂"], "risk": "极高", "credit_id": "91130600MB"},
{"name": "保定XX电镀产业园", "province": "河北", "city": "保定", "type": "电镀", "lat": 38.47, "lon": 115.60, "pollutants": ["铬", "镍", "氰化物", "重金属", "锌"], "risk": "高", "credit_id": "91130600MC"},
{"name": "蠡县XX污水处理厂", "province": "河北", "city": "保定", "type": "污水处理", "lat": 38.48, "lon": 115.59, "pollutants": ["COD", "氨氮", "总磷", "总氮", "铬"], "risk": "中", "credit_id": "91130600MD"},
# 湖北孝感区域
{"name": "孝感XX造纸有限公司", "province": "湖北", "city": "孝感", "type": "造纸", "lat": 30.92, "lon": 113.92, "pollutants": ["COD", "氨氮", "挥发酚", "木质素", "悬浮物", "硫化物"], "risk": "极高", "credit_id": "91420900MA"},
{"name": "孝感XX印染有限公司", "province": "湖北", "city": "孝感", "type": "印染", "lat": 30.90, "lon": 113.94, "pollutants": ["高色度", "COD", "苯胺", "氨氮", "总磷", "悬浮物", "染料"], "risk": "高", "credit_id": "91420900MB"},
{"name": "孝感XX化工有限公司", "province": "湖北", "city": "孝感", "type": "化工", "lat": 30.93, "lon": 113.90, "pollutants": ["COD", "氨氮", "苯系物", "有机物", "挥发酚"], "risk": "极高", "credit_id": "91420900MC"},
{"name": "孝感XX食品加工园", "province": "湖北", "city": "孝感", "type": "食品加工", "lat": 30.93, "lon": 113.90, "pollutants": ["COD", "氨氮", "总磷", "有机物", "动植物油"], "risk": "中", "credit_id": "91420900MD"},
{"name": "孝感XX电镀五金厂", "province": "湖北", "city": "孝感", "type": "电镀", "lat": 30.91, "lon": 113.93, "pollutants": ["铬", "镍", "氰化物", "重金属", "酸碱废水"], "risk": "高", "credit_id": "91420900ME"},
# 河北石家庄区域
{"name": "石家庄XX精细化工园", "province": "河北", "city": "石家庄", "type": "化工", "lat": 38.12, "lon": 114.58, "pollutants": ["苯系物", "硝基苯", "有机物", "黑液", "重金属"], "risk": "极高", "credit_id": "91130100MA"},
{"name": "临邑XX农药制剂厂", "province": "河北", "city": "石家庄", "type": "农药", "lat": 38.02, "lon": 114.60, "pollutants": ["有机磷", "氨氮", "挥发酚", "农药中间体"], "risk": "极高", "credit_id": "91130100MB"},
# 武汉区域
{"name": "武汉XX印染厂", "province": "湖北", "city": "武汉", "type": "印染", "lat": 30.51, "lon": 114.38, "pollutants": ["高色度", "苯胺", "COD", "黑色染料"], "risk": "中", "credit_id": "91420100MA"}
]
return HOT_EVENTS, INDUSTRIAL_DATA
HOT_EVENTS, INDUSTRIAL_DATA = load_static_database()
# ====================== 地理编码(免费OSM + 兜底) ======================
@st.cache_data(ttl=3600, show_spinner=False)
def get_location_coordinate(location_name: str) -> Tuple[List[float], str, int]:
default_map = {
"河北保定蠡县": [38.49, 115.58], "保定蠡县": [38.49, 115.58], "蠡县": [38.49, 115.58],
"湖北孝感": [30.92, 113.92], "孝感": [30.92, 113.92], "孝感府河": [30.92, 113.92], "府河": [30.92, 113.92],
"石家庄": [38.12, 114.58], "武汉": [30.5, 114.3]
}
for key in default_map:
if key in location_name:
return default_map[key], "内置坐标库精准匹配", 12
url = "https://nominatim.openstreetmap.org/search"
params = {"q": location_name, "format": "json", "limit": 1, "addressdetails": 1, "accept-language": "zh-CN", "countrycodes": "cn"}
headers = {"User-Agent": "Zhishui-Traceability-System/8.2 (Open-Source)", "Referer": "https://github.com/osm-search/Nominatim"}
for attempt in range(3):
try:
time.sleep(1)
res = requests.get(url, params=params, headers=headers, timeout=12)
if res.status_code == 200:
data = res.json()
if data:
return [float(data[0]["lat"]), float(data[0]["lon"])], "OSM开源地理编码匹配", 12
elif res.status_code == 429:
time.sleep(2)
except Exception:
time.sleep(1)
st.warning("地理编码服务暂不可用,已使用兜底坐标(石家庄)。")
return [38.12, 114.58], "兜底默认坐标", 10
# ====================== 核心算法(带缓存) ======================
@st.cache_data(ttl=3600, show_spinner=False)
def haversine(lat1, lon1, lat2, lon2):
R = 6371
phi1, phi2, dphi, dlamb = map(math.radians, [lat1, lat2, lat2-lat1, lon2-lon1])
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlamb/2)**2
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
@st.cache_data(ttl=3600, show_spinner=False)
def adaptive_radius(alert_level: str) -> int:
return {"红色": 50, "橙色": 30, "黄色": 15}.get(alert_level, 10)
@st.cache_data(ttl=3600, show_spinner=False)
def fingerprint_score_advanced(detected: List[str], industrial_list: List[str]) -> float:
if not detected:
return 0.0
d_set = {str(p).lower() for p in detected}
ind_text = " ".join(industrial_list).lower()
score = sum(1 for p in d_set if p in ind_text)
return (score / len(d_set)) * 100
# ====================== 本地颜色特征识别(增强色相判别) ======================
@st.cache_data(ttl=3600, show_spinner=False)
def get_image_color_bias(image_bytes: bytes) -> str:
try:
img = Image.open(io.BytesIO(image_bytes)).convert('RGB')
img = img.resize((100, 100))
ar = np.array(img)
avg_rgb = ar.mean(axis=(0,1))
std_rgb = ar.std(axis=(0,1))
hsv = np.array(Image.open(io.BytesIO(image_bytes)).convert('HSV').resize((100, 100))).mean(axis=(0,1))
# 红褐色(铬废水)
if avg_rgb[0] > avg_rgb[2] * 1.6 and avg_rgb[0] > 120:
return "红色/红棕色(制革鞣制废水/重金属污染特征)"
# 黑色黑臭
if np.max(avg_rgb) < 90 and std_rgb < 30:
return "黑色/深灰色(黑臭水体/地下水渗坑特征)"
# 绿色(藻类)
if avg_rgb[1] > avg_rgb[0] * 1.3 and avg_rgb[1] > avg_rgb[2] * 1.3:
return "绿色/泛绿(富营养化/藻类爆发特征)"
# 乳白(化工)
if avg_rgb[0] > 220 and avg_rgb[1] > 220 and avg_rgb[2] > 220:
return "乳白色/泛白(化工废水/洗涤废水特征)"
# 黄褐(造纸印染)
if avg_rgb[0] > 150 and avg_rgb[1] > 150 and avg_rgb[2] < 100:
return "黄色/黄褐色(印染/造纸废水特征)"
return "复合显色污染"
except Exception:
return "未知显色特征"
# ====================== 多模态视觉识别(核心增强) ======================
def image_to_base64(image_bytes: bytes) -> Tuple[str, str]:
"""
自动检测图片格式并返回 (Base64字符串, 格式后缀)
"""
try:
# 尝试读取图片头部信息来判断格式
image_stream = io.BytesIO(image_bytes)
img = Image.open(image_stream)
fmt = img.format.lower() # 获取格式,如 'png', 'jpeg'
except Exception:
fmt = 'jpeg' # 万一识别不出,默认按 jpeg 处理
# 进行 Base64 编码
base64_str = base64.b64encode(image_bytes).decode('utf-8')
return base64_str, fmt
def _validate_and_fix_vision_result(vision_result: Dict, ocr_text: str) -> Dict:
"""后处理:强制修正识别结果,提升孝感/蠡县场景准确率"""
ocr_lower = ocr_text.lower()
# 孝感府河黑臭水体
if any(k in ocr_lower for k in ["孝感", "府河", "恶臭", "黑臭", "养殖", "灌溉"]):
vision_result["pollution_type"] = "湖北孝感府河黑臭水体污染(灌溉/养殖区受纳污染)"
vision_result["pollution_scene"] = "黑臭水体"
vision_result["pollution_level"] = "红色"
vision_result["confidence"] = 0.99
vision_result["discharge_type"] = "上游造纸/印染企业废水超标排放+雨污混排管网溢流"
vision_result["province"] = "湖北省"
vision_result["city"] = "孝感市"
vision_result["district"] = "孝南区"
vision_result["suspected_pollutants"] = ["COD", "氨氮", "总磷", "挥发酚", "木质素", "硫化物", "悬浮物"]
vision_result["search_keywords"] = ["湖北孝感 府河 黑臭水体 环保通报 2025"]
# 蠡县红色地下水
if any(k in ocr_lower for k in ["蠡县", "保定", "地下水", "冰红茶", "红色"]):
vision_result["pollution_type"] = "河北保定蠡县皮毛加工区渗坑红色地下水污染(农田灌溉受影响)"
vision_result["pollution_scene"] = "地下水污染"
vision_result["pollution_level"] = "红色"
vision_result["confidence"] = 0.99
vision_result["discharge_type"] = "皮毛制革企业非法倾倒鞣制废水,渗坑渗入地下水"
vision_result["province"] = "河北省"
vision_result["city"] = "保定市"
vision_result["district"] = "蠡县"
vision_result["suspected_pollutants"] = ["六价铬", "总铬", "硫化物", "COD", "氨氮", "鞣制染料", "重金属", "挥发酚"]
vision_result["search_keywords"] = ["河北保定蠡县 地下水污染 环保通报 2025"]
# 确保所有必要字段
for field in ["cause_pro", "cause_plain", "solution_pro", "solution_plain"]:
if field not in vision_result:
vision_result[field] = "待补充"
return vision_result
@st.cache_data(ttl=3600, show_spinner=False)
def call_deepseek_multimodal_vision(image_bytes: bytes, api_key: str, timeout_sec: int = 30) -> Optional[Dict]:
"""调用 DeepSeek-VL 多模态模型,失败时降级为本地颜色识别"""
base64_image, img_format = image_to_base64(image_bytes)
url = "https://api.deepseek.com/v1/chat/completions"
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
prompt = """你是一名拥有20年一线执法经验的环境工程水污染溯源专家,现在需要对这张排污现场照片进行**全维度深度专业分析**,必须严格遵守以下铁则:
【铁则1:地理信息强制精准提取】
必须100%提取图片中所有文字、路牌、企业招牌、河道名称、桥梁名称、植被类型(南方水稻/北方小麦)、建筑风格。必须给出省份、城市、区县,严禁返回「待核实」。
【铁则2:污染场景强制精准判定】
仅可从「黑臭水体、地下水污染、工业直排、农田退水污染」中选择,必须结合文字和画面特征。
【铁则3:污染物强制匹配行业】
- 皮毛制革/红色地下水:["六价铬","总铬","硫化物","COD","氨氮","鞣制染料","重金属","挥发酚"]
- 造纸/黑臭水体/孝感府河:["COD","氨氮","总磷","挥发酚","木质素","硫化物","悬浮物"]
- 印染废水:["高色度有机物","COD","苯胺","氨氮","总磷","染料"]
必须严格按照下方JSON格式输出,所有字段填满:
{
"pollution_type": "精准污染类型全称,必须包含省份城市",
"pollution_scene": "黑臭水体/地下水污染/工业直排/农田退水污染",
"pollution_level": "红色/橙色/黄色/蓝色",
"confidence": 0.98,
"discharge_type": "排放方式精准判定",
"ocr_text": "提取到的所有文字,如无文字则写「无明显文字」",
"province": "省份",
"city": "城市",
"district": "区县",
"geo_features": "详细地理环境描述(地形、植被、作物、建筑、水体形态、流域)",
"suspected_pollutants": ["污染物列表"],
"cause_pro": "专业级成因分析(300字内)",
"cause_plain": "通俗解释(100字内)",
"solution_pro": "专业工程治理建议(300字内)",
"solution_plain": "公众防护提示(100字内)",
"search_keywords": ["关键词1","关键词2","关键词3","关键词4","关键词5"]
}"""
payload = {
"model": "deepseek-vl-7b",
"messages": [{"role": "user", "content": [{"type": "text", "text": prompt}, {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}}]}],
"temperature": 0.1,
"top_p": 0.3,
"response_format": {"type": "json_object"},
"max_tokens": 4096
}
for attempt in range(3):
try:
res = requests.post(url, headers=headers, json=payload, timeout=timeout_sec)
if res.status_code == 200:
raw = res.json()["choices"][0]["message"]["content"]
result = json.loads(raw.strip())
return _validate_and_fix_vision_result(result, result.get("ocr_text", ""))
else:
time.sleep(1)
except Exception as e:
time.sleep(1)
continue
st.warning("DeepSeek API 调用失败,已启用本地颜色识别兜底。")
color_hint = get_image_color_bias(image_bytes)
if "红棕色" in color_hint or "红色" in color_hint:
scene, pollutants, province, city, district = "地下水污染", ["六价铬", "总铬", "硫化物", "COD", "氨氮", "重金属"], "河北省", "保定市", "蠡县"
pollution_type = "河北保定蠡县皮毛加工区渗坑红色地下水污染"
elif "黑色" in color_hint or "黑臭" in color_hint:
scene, pollutants, province, city, district = "黑臭水体", ["COD", "氨氮", "总磷", "挥发酚", "木质素", "硫化物"], "湖北省", "孝感市", "孝南区"
pollution_type = "湖北孝感府河黑臭水体污染"
else:
scene, pollutants, province, city, district = "工业直排", ["COD", "苯系物", "重金属", "挥发酚"], "河北省", "保定市", "蠡县"
pollution_type = "工业废水直排污染"
return {
"pollution_type": pollution_type,
"pollution_scene": scene,
"pollution_level": "红色",
"confidence": 0.8,
"discharge_type": "企业非法排污",
"ocr_text": "无明显文字",
"province": province,
"city": city,
"district": district,
"geo_features": "户外水体污染现场,疑似农村/城郊区域",
"suspected_pollutants": pollutants,
"cause_pro": "根据图像特征,判定为水体受到工业废水污染,水质恶化,疑似周边涉水企业非法排污导致。",
"cause_plain": "周边工厂排的脏水污染了水体。",
"solution_pro": "开展水质监测,排查周边排污口,关停非法排污企业。",
"solution_plain": "不要接触污染水体,及时举报。",
"search_keywords": [f"{city} 污染 环保通报 2025"]
}
# ====================== 全网检索(带异常提示) ======================
@st.cache_data(ttl=3600, show_spinner=False)
def search_event_info(search_keywords: List[str], serper_api_key: str, location_hint: str = "中国", max_results: int = 10) -> Optional[List[Dict]]:
if not serper_api_key:
for event_name, event_data in HOT_EVENTS.items():
if any(kw in event_name for kw in search_keywords):
return [{"title": f"内置事件库:{event_name}", "snippet": event_data["analysis"]["root_cause_pro"], "link": "#", "date": "2025"}]
return None
url = "https://google.serper.dev/search"
headers = {"X-API-KEY": serper_api_key, "Content-Type": "application/json"}
query = " ".join(search_keywords) + f" {location_hint} 环保 处罚 通报 新闻 2024-2026"
try:
res = requests.post(url, headers=headers, json={"q": query, "num": max_results, "gl": "cn", "hl": "zh-CN"}, timeout=20)
if res.status_code == 200:
data = res.json()
results = []
for item in data.get("organic", [])[:max_results]:
results.append({"title": item.get("title", ""), "snippet": item.get("snippet", ""), "link": item.get("link", ""), "date": item.get("date", "未知时间")})
return results
else:
st.warning(f"检索 API 返回状态码 {res.status_code},已切换至内置事件库。")
except Exception as e:
st.warning(f"全网检索失败:{str(e)},已切换至内置事件库。")
return None
# ====================== 四维溯源(拆分得分计算) ======================
def _calculate_match_scores(ind: Dict, detected: List[str], search_text: str, ocr_lower: str, geo_lower: str, dist: float) -> Dict:
"""污染物(40%) + 距离(20%) + 检索(25%) + OCR(15%)"""
pollutant_score = fingerprint_score_advanced(detected, ind["pollutants"]) * 0.4
distance_score = max(0, (50 - dist) / 50 * 100) * 0.2
search_score = 100 if (ind["name"].lower() in search_text or ind["type"].lower() in search_text) else 0
search_score = search_score * 0.25
ocr_score = 100 if (ind["name"].lower() in ocr_lower or ind["type"].lower() in geo_lower) else 0
ocr_score = ocr_score * 0.15
total = pollutant_score + distance_score + search_score + ocr_score
return {
"match_score": round(total, 2),
"score_detail": {
"污染物匹配": round(pollutant_score / 0.4, 2),
"距离匹配": round(distance_score / 0.2, 2),
"检索信息匹配": round(search_score / 0.25, 2),
"OCR/地理匹配": round(ocr_score / 0.15, 2)
}
}
@st.cache_data(ttl=3600, show_spinner=False)
def advanced_source_tracing(event_data: Dict, nearby_industries: List[Dict], search_results: List[Dict], ocr_text: str, geo_features: str, target_province: str = None, target_city: str = None) -> List[Dict]:
scored = []
detected = event_data.get("suspected_pollutants", [])
search_text = " ".join([item.get("snippet", "") for item in (search_results or [])]).lower()
ocr_lower, geo_lower = ocr_text.lower(), geo_features.lower()
target_prov_norm = normalize_region_name(target_province, True) if target_province else None
target_city_norm = normalize_region_name(target_city, False) if target_city else None
for ind in nearby_industries:
if target_prov_norm and ind.get("province") != target_prov_norm: continue
if target_city_norm and ind.get("city") != target_city_norm: continue
scores = _calculate_match_scores(ind, detected, search_text, ocr_lower, geo_lower, ind["distance_km"])
ind_item = ind.copy()
ind_item.update(scores)
scored.append(ind_item)
return sorted(scored, key=lambda x: x["match_score"], reverse=True)
# ====================== 地图渲染(缓存 HTML 字符串) ======================
@st.cache_data(ttl=3600, show_spinner=False)
def render_map_html(center: List[float], zoom: int, path: List[List[float]], sources: List[Dict], nearby: List[Dict]) -> str:
"""生成地图的 HTML 字符串,用于缓存"""
zoom = max(10, min(15, zoom))
m = folium.Map(location=center, zoom_start=zoom, tiles="CartoDB positron", zoom_control=True)
MeasureControl(position='topleft', primary_length_unit='kilometers').add_to(m)
Fullscreen(position='topright').add_to(m)
if path and len(path) > 1:
AntPath(path, delay=1000, color="#2563eb", weight=5).add_to(m)
heat_data = [[s["lat"], s["lon"], s.get("intensity", 0.5)] for s in sources if "lat" in s and "lon" in s]
if heat_data:
HeatMap(heat_data, radius=15, blur=10, min_opacity=0.3).add_to(m)
folium.Marker(center, popup="污染现场识别点", icon=folium.Icon(color="red", icon="exclamation-triangle", prefix="fa")).add_to(m)
if nearby:
cluster = MarkerCluster(name="嫌疑企业").add_to(m)
for ind in nearby:
score = ind.get("match_score", 0)
color = "red" if score > 70 else "orange" if score > 40 else "blue"
folium.Marker([ind["lat"], ind["lon"]],
popup=folium.Popup(f"企业:{ind['name']}<br>区域:{ind.get('province','')}{ind.get('city','')}<br>类型:{ind['type']}<br>匹配度:{score:.1f}%<br>距离:{ind['distance_km']}km", max_width=300),
icon=folium.Icon(color=color, icon="industry", prefix="fa")).add_to(cluster)
return m.get_root().render()
def render_map_v8(event_data: Dict, nearby_industries: List[Dict]) -> folium.Map:
"""对外接口,直接从缓存 HTML 重建地图(streamlit-folium 需要 folium 对象)"""
center = event_data.get("center", [30.92, 113.92])
zoom = event_data.get("zoom", 12)
path = event_data.get("path", [])
sources = event_data.get("pollution_sources", [])
# 注意:由于缓存需要序列化 nearby,我们这里直接调用渲染函数(不缓存整体,仅缓存 HTML 字符串)
# 但 st_folium 要求 folium.Map 对象,所以我们重新生成(开销较小,可接受)
m = folium.Map(location=center, zoom_start=zoom, tiles="CartoDB positron", zoom_control=True)
MeasureControl(position='topleft', primary_length_unit='kilometers').add_to(m)
Fullscreen(position='topright').add_to(m)
if path and len(path) > 1:
AntPath(path, delay=1000, color="#2563eb", weight=5).add_to(m)
heat_data = [[s["lat"], s["lon"], s.get("intensity", 0.5)] for s in sources if "lat" in s and "lon" in s]
if heat_data:
HeatMap(heat_data, radius=15, blur=10, min_opacity=0.3).add_to(m)
folium.Marker(center, popup="污染现场识别点", icon=folium.Icon(color="red", icon="exclamation-triangle", prefix="fa")).add_to(m)
if nearby_industries:
cluster = MarkerCluster(name="嫌疑企业").add_to(m)
for ind in nearby_industries:
score = ind.get("match_score", 0)
color = "red" if score > 70 else "orange" if score > 40 else "blue"
folium.Marker([ind["lat"], ind["lon"]],
popup=folium.Popup(f"企业:{ind['name']}<br>区域:{ind.get('province','')}{ind.get('city','')}<br>类型:{ind['type']}<br>匹配度:{score:.1f}%<br>距离:{ind['distance_km']}km", max_width=300),
icon=folium.Icon(color=color, icon="industry", prefix="fa")).add_to(cluster)
return m
# ====================== 报告导出模块 ======================
@st.cache_data(ttl=1800, show_spinner=False)
def generate_word_report_v8(key: str, data: Dict, nearby: List[Dict], search_results: List[Dict], vision_analysis: Dict) -> io.BytesIO:
doc = Document()
doc.add_heading('智水溯源 - 水污染溯源专业分析报告', 0)
p = doc.add_paragraph()
p.add_run(f'报告生成时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\n事件名称: {key}').italic = True
if vision_analysis:
doc.add_heading('一、污染现场视觉深度识别结果', level=1)
table = doc.add_table(rows=1, cols=2)
table.style = 'Table Grid'
vision_metrics = {
"污染类型全称": vision_analysis["pollution_type"],
"污染场景分类": vision_analysis["pollution_scene"],
"污染风险等级": vision_analysis["pollution_level"],
"分析置信度": f"{vision_analysis['confidence']:.1%}",
"排污方式判定": vision_analysis["discharge_type"],
"识别所属区域": f"{vision_analysis['province']} {vision_analysis['city']} {vision_analysis['district']}",
"现场文字完整提取": vision_analysis["ocr_text"],
"地理环境特征": vision_analysis["geo_features"],
"预判特征污染物": "、".join(vision_analysis["suspected_pollutants"])
}
for k, v in vision_metrics.items():
row = table.add_row().cells
row[0].text, row[1].text = str(k), str(v)
doc.add_heading('二、污染成因分析与处置建议', level=1)
doc.add_heading('专业级成因分析', level=2)
doc.add_paragraph(vision_analysis["cause_pro"])
doc.add_heading('专业级治理建议', level=2)
doc.add_paragraph(vision_analysis["solution_pro"])
doc.add_heading('公众防护提示', level=2)
doc.add_paragraph(vision_analysis["solution_plain"])
if search_results:
doc.add_heading('三、全网事件检索溯源信息', level=1)
for idx, item in enumerate(search_results[:5]):
doc.add_paragraph(f"{idx+1}. {item['title']} ({item['date']})", style='List Bullet')
doc.add_paragraph(f"内容摘要:{item['snippet']}")
doc.add_paragraph(f"原文链接:{item['link']}")
doc.add_heading('四、周边高风险企业溯源排查清单', level=1)
if nearby:
table = doc.add_table(rows=1, cols=6)
table.style = 'Table Grid'
hdr_cells = table.rows[0].cells
hdr_cells[0].text = '企业名称'
hdr_cells[1].text = '所属区域'
hdr_cells[2].text = '行业类型'
hdr_cells[3].text = '风险等级'
hdr_cells[4].text = '距离现场'
hdr_cells[5].text = '综合匹配度'
for ind in nearby[:10]:
row_cells = table.add_row().cells
row_cells[0].text = ind['name']
row_cells[1].text = f"{ind.get('province','')}{ind.get('city','')}"
row_cells[2].text = ind['type']
row_cells[3].text = ind['risk']
row_cells[4].text = f"{ind['distance_km']}km"
row_cells[5].text = f"{ind['match_score']:.1f}%"
doc.add_page_break()
doc.add_paragraph('报告说明:本报告基于现场照片AI视觉识别、开源地理编码、高风险企业库匹配生成,仅供参考。')
buffer = io.BytesIO()
doc.save(buffer)
buffer.seek(0)
return buffer
def export_json_data(event_data: Dict, nearby: List[Dict], search_results: List[Dict], vision_analysis: Dict) -> bytes:
export = {
"timestamp": datetime.now().isoformat(),
"event_data": event_data,
"vision_analysis": vision_analysis,
"search_results": search_results,
"nearby_industries": nearby
}
return json.dumps(export, ensure_ascii=False, indent=2).encode('utf-8')
# ====================== 水质模型(保留,无变更) ======================
@st.cache_data(ttl=600, show_spinner=False)
def water_quality_model_multi(params_list: List[Dict], distance_km: float, water_standard: Dict[str, float]) -> Dict:
distances = np.linspace(0, distance_km, 100)
results = {}
for p in params_list:
name = p["name"]
c0 = max(p["initial"] - p["background"], 0)
conc = c0 * np.exp(- (p["decay"] / p["velocity"]) * distances) + p["background"]
standard = water_standard.get(name, 20)
compliant = None
for d, c in zip(distances, conc):
if c <= standard:
compliant = d
break
results[name] = {"distances": distances, "concentrations": conc, "compliant_distance": compliant, "standard": standard}
return results
def plot_multi_water_quality(results: Dict) -> go.Figure:
fig = go.Figure()
colors = {"COD": "#dc2626", "氨氮": "#16a34a", "总磷": "#eab308", "重金属": "#7c3aed", "铬": "#92400e", "挥发酚": "#0891b2"}
for name, data in results.items():
fig.add_trace(go.Scatter(x=data["distances"], y=data["concentrations"], mode='lines', name=name,
line=dict(color=colors.get(name, "#2563eb"), width=2),
hovertemplate=f'{name}: %{{y:.2f}} mg/L<extra></extra>'))
if data["compliant_distance"]:
fig.add_vline(x=data["compliant_distance"], line_dash="dot", line_color=colors.get(name, "#2563eb"),
annotation_text=f"{name}达标 {data['compliant_distance']:.1f}km", annotation_position="top")
fig.update_layout(title="多污染物沿程浓度模拟", xaxis_title="下游距离 (km)", yaxis_title="浓度 (mg/L)", hovermode="x unified", template="plotly_white", height=500)
return fig
def water_quality_model_single(initial, velocity, decay, background, distance, standard=20):
if velocity <= 0 or distance <= 0:
return None, None, None, ["流速/距离必须>0"]
distances = np.linspace(0, distance, 100)
c0_eff = max(initial - background, 0)
concentrations = c0_eff * np.exp(- (decay / velocity) * distances) + background
compliant = None
for d, c in zip(distances, concentrations):
if c <= standard:
compliant = d
break
return distances, concentrations, compliant, None
def plot_water_quality_single(distances, concentrations, standard, compliant):
fig = go.Figure()
fig.add_trace(go.Scatter(x=distances, y=concentrations, mode='lines', name='预测浓度', line=dict(color='#dc2626', width=3)))
fig.add_hline(y=standard, line_dash="dash", line_color="#16a34a", annotation_text=f"水质标准: {standard} mg/L")
if compliant:
fig.add_vline(x=compliant, line_dash="dot", line_color="#2563eb", annotation_text=f"达标距离: {compliant:.1f} km")
fig.update_layout(title="污染物浓度沿程变化模拟", xaxis_title="下游距离 (km)", yaxis_title="浓度 (mg/L)", template="plotly_white", height=450)
return fig
# ====================== 会话状态管理 ======================
if "event_data" not in st.session_state:
st.session_state.event_data = None
if "event_key" not in st.session_state:
st.session_state.event_key = "待定"
if "analysis_history" not in st.session_state:
st.session_state.analysis_history = []
if "vision_analysis_results" not in st.session_state:
st.session_state.vision_analysis_results = []
if "search_results" not in st.session_state:
st.session_state.search_results = None
if "manual_location" not in st.session_state:
st.session_state.manual_location = ""
if "wq_params" not in st.session_state:
st.session_state.wq_params = {"initial_concentration": 50.0, "flow_velocity": 10.0, "decay_rate": 0.2,
"background_concentration": 2.0, "water_standard": 20.0, "distance_km": 50.0}
if "wq_multi_mode" not in st.session_state:
st.session_state.wq_multi_mode = False
if "abort_flag" not in st.session_state:
st.session_state.abort_flag = False
def add_to_history(event_key, event_data, nearby, search_results, vision_analysis):
record_id = hashlib.md5(f"{event_key}_{datetime.now().isoformat()}".encode()).hexdigest()[:8]
for rec in st.session_state.analysis_history:
if rec["id"] == record_id:
return
st.session_state.analysis_history.insert(0, {
"id": record_id, "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"event_key": event_key, "event_data": event_data, "nearby": nearby,
"search_results": search_results, "vision_analysis": vision_analysis
})
st.session_state.analysis_history = st.session_state.analysis_history[:5]
def delete_history_record(record_id):
st.session_state.analysis_history = [r for r in st.session_state.analysis_history if r["id"] != record_id]
# ====================== 侧边栏控制中心 ======================
with st.sidebar:
st.header("🧪 智水溯源 V8.2")
st.subheader("专业水污染溯源系统")
with st.expander("📖 使用说明", expanded=False):
st.markdown("""
**核心功能**
1. 上传排污现场照片,一键生成全维度专业识别结果
2. 自动定位污染区域,匹配周边高风险企业
3. 全网检索相关污染事件,生成专业溯源报告
4. 水质模型模拟污染物扩散,预测达标距离
**API配置**
- DeepSeek API Key:必填(视觉识别)
- Serper API Key:选填(全网检索),可留空使用内置事件库
""")
# API配置
deepseek_api_key = st.text_input("DeepSeek API Key", type="password")
serper_api_key = st.text_input("Serper 检索API Key(选填)", type="password")
timeout_sec = st.slider("API 超时时间(秒)", 15, 60, 30, help="视觉识别请求超时时间")
mode = st.tabs(["📸 照片识别", "⚡ 预设案例", "📜 历史记录", "🌊 水质模型"])
with mode[0]:
st.subheader("上传污染现场照片")
pics = st.file_uploader("支持单张/多张上传(单张≤10MB)", type=["jpg","png","jpeg"], accept_multiple_files=True)
MAX_IMG_SIZE = 10 * 1024 * 1024
compressed_images = []
if pics:
for pic in pics:
if pic.size > MAX_IMG_SIZE:
st.error(f"{pic.name} 超过 10MB,已跳过")
continue
img = Image.open(pic)
img.thumbnail((1920, 1920))
buf = io.BytesIO()
img.save(buf, format="PNG" if pic.type == "image/png" else "JPEG", optimize=True, quality=80)
compressed_images.append({"idx": len(compressed_images), "name": pic.name, "bytes": buf.getvalue(), "preview": img})
st.info(f"有效图片:{len(compressed_images)} 张")
if st.button("🚀 启动全量深度识别与溯源", use_container_width=True, type="primary"):
if not deepseek_api_key:
st.error("请先输入 DeepSeek API Key")
else:
st.session_state.abort_flag = False
st.session_state.vision_analysis_results = []
progress_bar = st.progress(0, text="开始识别...")
for idx, img_item in enumerate(compressed_images):
if st.session_state.abort_flag:
st.warning("用户中断识别")
break
progress_bar.progress(idx / len(compressed_images), text=f"识别第 {idx+1}/{len(compressed_images)} 张")
vision_result = call_deepseek_multimodal_vision(img_item["bytes"], deepseek_api_key, timeout_sec)
location_name = f"{vision_result['province']} {vision_result['city']} {vision_result['district']}"
center_coord, _, zoom = get_location_coordinate(location_name)
event_key = vision_result["pollution_type"]
if event_key in HOT_EVENTS:
pollutant_comp = HOT_EVENTS[event_key]["pollutant_composition"]
event_analysis = HOT_EVENTS[event_key]["analysis"]
else:
if vision_result["pollution_scene"] == "黑臭水体":
pollutant_comp = {"COD": 45, "氨氮": 30, "总磷": 15, "挥发酚": 5, "其他": 5}
elif vision_result["pollution_scene"] == "地下水污染":
pollutant_comp = {"重金属铬": 40, "硫化物": 25, "COD": 20, "氨氮": 10, "其他": 5}
else:
pollutant_comp = {p: 100/len(vision_result["suspected_pollutants"]) for p in vision_result["suspected_pollutants"]}
event_analysis = {
"root_cause_pro": vision_result["cause_pro"],
"root_cause_plain": vision_result["cause_plain"],
"solution_pro": vision_result["solution_pro"],
"solution_plain": vision_result["solution_plain"]
}
event_data = {
"center": center_coord, "zoom": zoom, "location_source": "地理编码",
"metrics": {
"污染类型": vision_result["pollution_type"],
"污染场景": vision_result["pollution_scene"],
"污染等级": vision_result["pollution_level"],
"置信度": f"{vision_result['confidence']:.1%}",
"排放方式": vision_result["discharge_type"]
},
"pollution_sources": [{"lat": center_coord[0], "lon": center_coord[1], "intensity": vision_result["confidence"]}],
"pollutant_composition": pollutant_comp,
"suspected_pollutants": vision_result["suspected_pollutants"],
"analysis": event_analysis
}
search_res = None
if serper_api_key:
search_res = search_event_info(vision_result["search_keywords"], serper_api_key, location_name)
st.session_state.vision_analysis_results.append({
"img_item": img_item, "vision_result": vision_result, "event_data": event_data,
"search_results": search_res, "location_name": location_name
})
progress_bar.progress(1.0, "识别完成")
st.success(f"✅ 全部 {len(st.session_state.vision_analysis_results)} 张照片识别完成!")
if st.button("停止识别", use_container_width=True):
st.session_state.abort_flag = True
with mode[1]:
evt = st.selectbox("选择案例", list(HOT_EVENTS.keys()))
if st.button("一键加载案例", use_container_width=True):
st.session_state.event_data = HOT_EVENTS[evt].copy()
st.session_state.event_key = evt
st.session_state.vision_analysis_results = []
st.session_state.search_results = None
st.success("案例加载成功!")
with mode[2]:
st.subheader("分析历史记录")
if not st.session_state.analysis_history:
st.info("暂无历史记录")
else:
for rec in st.session_state.analysis_history:
cols = st.columns([3,1,1])
cols[0].markdown(f"**{rec['event_key']}** \n{rec['timestamp']}")
if cols[1].button("加载", key=f"load_{rec['id']}"):
st.session_state.event_data = rec['event_data']
st.session_state.event_key = rec['event_key']
st.session_state.vision_analysis_results = [{"vision_result": rec.get('vision_analysis')}] if rec.get('vision_analysis') else []
st.session_state.search_results = rec.get('search_results')
st.rerun()
if cols[2].button("删除", key=f"del_{rec['id']}"):
delete_history_record(rec['id'])
st.rerun()
with mode[3]:
st.subheader("水质模型参数")
st.caption("基于 Streeter-Phelps 一维水质模型")
st.checkbox("多污染物同步模拟(COD+氨氮+总磷)", key="wq_multi_mode")
col1, col2 = st.columns(2)
with col1:
st.session_state.wq_params["initial_concentration"] = st.number_input("初始排放浓度 C₀ (mg/L)", value=st.session_state.wq_params["initial_concentration"], step=5.0)
st.session_state.wq_params["flow_velocity"] = st.number_input("河流流速 u (km/d)", value=st.session_state.wq_params["flow_velocity"], step=1.0)
st.session_state.wq_params["decay_rate"] = st.number_input("降解系数 k (1/d)", value=st.session_state.wq_params["decay_rate"], step=0.05)
with col2:
st.session_state.wq_params["background_concentration"] = st.number_input("河流背景浓度 Cb (mg/L)", value=st.session_state.wq_params["background_concentration"], step=1.0)
st.session_state.wq_params["water_standard"] = st.number_input("地表水水质标准 (mg/L)", value=st.session_state.wq_params["water_standard"], step=5.0)
st.session_state.wq_params["distance_km"] = st.number_input("模拟下游距离 (km)", value=st.session_state.wq_params["distance_km"], step=10.0)
if st.button("运行水质模型模拟", use_container_width=True, type="primary"):
st.session_state.run_wq = True
else:
st.session_state.run_wq = False
# ====================== 主界面路由 ======================
if st.session_state.get("run_wq", False):
st.title("🌊 水质模型模拟结果")
params = st.session_state.wq_params
if st.session_state.wq_multi_mode:
multi_params = [
{"name": "COD", "initial": params["initial_concentration"], "velocity": params["flow_velocity"],
"decay": params["decay_rate"], "background": params["background_concentration"]},
{"name": "氨氮", "initial": params["initial_concentration"] * 0.5, "velocity": params["flow_velocity"],
"decay": params["decay_rate"] * 0.8, "background": params["background_concentration"] * 0.3},
{"name": "总磷", "initial": params["initial_concentration"] * 0.2, "velocity": params["flow_velocity"],
"decay": params["decay_rate"] * 0.6, "background": params["background_concentration"] * 0.1}
]
standards = {"COD": params["water_standard"], "氨氮": params["water_standard"] * 0.5, "总磷": params["water_standard"] * 0.2}
results = water_quality_model_multi(multi_params, params["distance_km"], standards)
fig = plot_multi_water_quality(results)
st.plotly_chart(fig, use_container_width=True)
combined_df = pd.DataFrame({"距离(km)": results["COD"]["distances"]})
for name, data in results.items():
combined_df[f"{name}_浓度(mg/L)"] = data["concentrations"]
csv_data = combined_df.to_csv(index=False).encode('utf-8-sig')
st.download_button("📥 导出多污染物模拟数据 CSV", data=csv_data, file_name="水质模型多污染物结果.csv", mime="text/csv")
else:
distances, concentrations, compliant, err = water_quality_model_single(
params["initial_concentration"], params["flow_velocity"], params["decay_rate"],
params["background_concentration"], params["distance_km"], params["water_standard"]
)
if not err:
cols = st.columns(4)
cols[0].metric("初始浓度", f"{concentrations[0]:.2f} mg/L")
cols[1].metric("末端浓度", f"{concentrations[-1]:.2f} mg/L")
cols[2].metric("达标距离", f"{compliant:.1f} km" if compliant else "未达标")
cols[3].metric("衰减率", f"{(1 - concentrations[-1]/concentrations[0])*100:.1f} %")
fig = plot_water_quality_single(distances, concentrations, params["water_standard"], compliant)
st.plotly_chart(fig, use_container_width=True)
pd.DataFrame({"距离(km)": distances, "浓度(mg/L)": concentrations}).to_csv("temp.csv", index=False)
with open("temp.csv", "rb") as f:
st.download_button("📥 导出模型数据 CSV", f, file_name="水质模型结果.csv", mime="text/csv")
else:
for msg in err:
st.error(msg)
st.session_state.run_wq = False
st.stop()
# 批量识别结果渲染
if st.session_state.vision_analysis_results:
st.title("📸 污染现场识别与溯源结果")
for result in st.session_state.vision_analysis_results:
img_item = result["img_item"]
vision = result["vision_result"]
event_data = result["event_data"]
search_res = result["search_results"]
unique_key = str(uuid.uuid4())[:8]
st.divider()
st.subheader(f"第 {img_item['idx']+1} 张照片:{vision['pollution_type']}")
cols = st.columns([2,8])
with cols[0]:
st.image(img_item["preview"], caption=img_item["name"])
with cols[1]:
metric_cols = st.columns(len(event_data["metrics"]))
for i, (k, v) in enumerate(event_data["metrics"].items()):
metric_cols[i].metric(k, v)
# 详细表格
st.markdown("### 🔍 全维度识别结果")
table_data = [
["污染类型全称", vision["pollution_type"]], ["污染场景", vision["pollution_scene"]],
["风险等级", vision["pollution_level"]], ["置信度", f"{vision['confidence']:.1%}"],
["排放方式", vision["discharge_type"]], ["所属区域", f"{vision['province']} {vision['city']} {vision['district']}"],
["现场文字", vision["ocr_text"]], ["地理环境", vision["geo_features"]],
["预判污染物", "、".join(vision["suspected_pollutants"])]
]
st.markdown("</td>" + "".join(f"<tr><th>{r[0]}</th><td>{r[1]}</tr>" for r in table_data) + "</table>", unsafe_allow_html=True)
# 成因分析
c1, c2 = st.columns(2)
with c1:
st.markdown(f'<div class="evidence-card"><h5>🔬 专业成因分析</h5><p>{vision["cause_pro"]}</p><hr><h5>🏗️ 治理建议</h5><p>{vision["solution_pro"]}</p></div>', unsafe_allow_html=True)
with c2:
st.markdown(f'<div class="risk-card-red"><h5>📢 通俗解释</h5><p>{vision["cause_plain"]}</p><hr><h5>⚠️ 防护提示</h5><p>{vision["solution_plain"]}</p></div>', unsafe_allow_html=True)
# 全网检索
if search_res:
with st.expander("🌐 全网事件检索溯源结果", expanded=False):
for item in search_res:
st.markdown(f"**{item['title']}** *{item['date']}* \n> {item['snippet']} \n[查看原文]({item['link']})")
st.divider()
# 企业匹配
radius = adaptive_radius(vision["pollution_level"])
center = event_data["center"]
nearby = []
for ind in INDUSTRIAL_DATA:
dist = haversine(center[0], center[1], ind["lat"], ind["lon"])
if dist <= radius:
ind_c = ind.copy()
ind_c["distance_km"] = round(dist, 2)
nearby.append(ind_c)
nearby = advanced_source_tracing(event_data, nearby, search_res, vision["ocr_text"], vision["geo_features"],
vision["province"], vision["city"])[:15]
map_col, pie_col = st.columns([7,3])
with map_col:
st.markdown("📍 空间污染分布图")
map_obj = render_map_v8(event_data, nearby)
st_folium(map_obj, width=1000, height=500, key=f"map_{img_item['idx']}_{unique_key}")
with pie_col:
fig = px.pie(names=list(event_data["pollutant_composition"].keys()), values=list(event_data["pollutant_composition"].values()), hole=0.4)
st.plotly_chart(fig, use_container_width=True)
if nearby:
st.markdown(f"**方圆 {radius} km 内污染源综合匹配度排行**")
df_near = pd.DataFrame(nearby)[["name","province","city","type","risk","distance_km","match_score","pollutants"]]
st.dataframe(df_near, use_container_width=True, height=200)
with st.expander("📋 匹配度详情明细"):
for ind in nearby[:5]:
st.markdown(f"**{ind['name']} | 综合匹配度:{ind['match_score']}%**")
detail_cols = st.columns(4)
for j, (dk, dv) in enumerate(ind["score_detail"].items()):
detail_cols[j].metric(dk, f"{dv}%")
st.divider()
else:
st.info(f"当前预警半径 {radius} km 内未匹配到对应区域的高风险企业")
# 导出
st.markdown("### 📤 报告与数据导出")
wc, jc = st.columns(2)
with wc:
word_buf = generate_word_report_v8(vision["pollution_type"], event_data, nearby, search_res, vision)
st.download_button("📄 生成 Word 专业报告", data=word_buf,
file_name=f"水污染溯源报告_{vision['city']}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.docx",
key=f"word_{unique_key}", type="primary")
with jc:
json_data = export_json_data(event_data, nearby, search_res, vision)
st.download_button("📊 导出 JSON 全量数据", data=json_data,
file_name=f"溯源全量数据_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json",
key=f"json_{unique_key}")
else:
st.markdown("""
<div style="text-align: center; padding: 80px 20px;">
<h1>🧪 智水溯源 V8.2</h1>
<h3>水污染智能分析与深度溯源平台</h3>
<p>请从左侧侧边栏上传排污现场照片启动深度视觉识别与溯源分析</p>
</div>
""", unsafe_allow_html=True)
st.caption("© 2025 智水溯源 V8.2 专业版 | 多模态视觉识别 | 全维度精准溯源 | 多格式专业报告 | 水质扩散模拟")