Spaces:
Sleeping
Sleeping
Upload 13 files
Browse files- app.py +367 -0
- download_data.py +46 -0
- download_text_data.py +42 -0
- image_module.py +172 -0
- mobilenet_finetuned.pth +3 -0
- packages.txt +1 -0
- requirements.txt +12 -3
- test.py +2 -0
- text_module.py +116 -0
- train_image_model.py +81 -0
- train_text_model.py +96 -0
- tune_thresholds.py +47 -0
- video_module.py +68 -0
app.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import os
|
| 3 |
+
import time
|
| 4 |
+
from PIL import Image
|
| 5 |
+
|
| 6 |
+
# 全局缓存 Whisper 语音大模型
|
| 7 |
+
@st.cache_resource
|
| 8 |
+
def load_whisper_model():
|
| 9 |
+
import whisper
|
| 10 |
+
return whisper.load_model("small")
|
| 11 |
+
|
| 12 |
+
# 页面配置
|
| 13 |
+
st.set_page_config(
|
| 14 |
+
page_title="多模态AI生成痕迹鉴别系统",
|
| 15 |
+
page_icon="🔍",
|
| 16 |
+
layout="wide"
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
# ==========================================
|
| 20 |
+
# 侧边栏:系统设置与说明
|
| 21 |
+
# ==========================================
|
| 22 |
+
with st.sidebar:
|
| 23 |
+
st.title("⚙️ 鉴别引擎设置")
|
| 24 |
+
st.markdown("提供轻量、可解释的多模态AI生成内容快速鉴别能力。")
|
| 25 |
+
st.divider()
|
| 26 |
+
|
| 27 |
+
st.header("🎚️ 动态判定阈值")
|
| 28 |
+
st.markdown("调整敏感度,适应不同审核场景:")
|
| 29 |
+
high_risk_threshold = st.slider(
|
| 30 |
+
"高危报警阈值",
|
| 31 |
+
min_value=0.60, max_value=0.95, value=0.80, step=0.05,
|
| 32 |
+
help="高于此值判定为“高度疑似AI生成”"
|
| 33 |
+
)
|
| 34 |
+
warning_threshold = st.slider(
|
| 35 |
+
"存疑缓冲阈值",
|
| 36 |
+
min_value=0.20, max_value=0.55, value=0.40, step=0.05,
|
| 37 |
+
help="介于警告阈值与报警阈值之间时,标记为“存疑”"
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
st.divider()
|
| 41 |
+
st.caption("🔹 引擎状态:轻量模型已缓存 · 本地推理")
|
| 42 |
+
|
| 43 |
+
# ==========================================
|
| 44 |
+
# 主界面标题
|
| 45 |
+
# ==========================================
|
| 46 |
+
st.title("🔍 多模态AI生成痕迹快速鉴别系统")
|
| 47 |
+
st.markdown(
|
| 48 |
+
"同时支持**图像、文本、视频**单模态扫描,以及**图文/视文**联合跨模态融合鉴定。"
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
work_mode = st.radio(
|
| 52 |
+
"请选择检测模式:",
|
| 53 |
+
["单模态独立检测(图片 / 文本 / 视频)", "多模态联合检测(图片+文案 / 视频+文案)"],
|
| 54 |
+
horizontal=True
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
st.divider()
|
| 58 |
+
|
| 59 |
+
# ==========================================
|
| 60 |
+
# 模式一:单模态独立检测
|
| 61 |
+
# ==========================================
|
| 62 |
+
if "单模态独立检测" in work_mode:
|
| 63 |
+
with st.container(border=True):
|
| 64 |
+
uploaded_file = st.file_uploader(
|
| 65 |
+
"拖拽或点击上传待检测文件",
|
| 66 |
+
type=["jpg", "png", "jpeg", "txt", "docx", "mp4", "avi", "mov"],
|
| 67 |
+
help="支持常见图片、文本、视频格式,单文件≤50MB,视频时长建议≤5分钟"
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
if uploaded_file is not None:
|
| 71 |
+
file_type = uploaded_file.name.split('.')[-1].lower()
|
| 72 |
+
st.success(f"✅ 已接收:**{uploaded_file.name}** | 启动鉴别引擎...")
|
| 73 |
+
|
| 74 |
+
col_content, col_result = st.columns([1, 1.2], gap="large")
|
| 75 |
+
|
| 76 |
+
# ----------------- 图像检测 -----------------
|
| 77 |
+
if file_type in ['jpg', 'png', 'jpeg']:
|
| 78 |
+
with col_content:
|
| 79 |
+
st.subheader("原始图像")
|
| 80 |
+
st.image(uploaded_file, use_container_width=True)
|
| 81 |
+
|
| 82 |
+
st.subheader("AI痕迹热力图(Grad-CAM)")
|
| 83 |
+
heatmap_placeholder = st.empty()
|
| 84 |
+
|
| 85 |
+
with col_result:
|
| 86 |
+
st.subheader("⚙️ 特征提取中...")
|
| 87 |
+
progress_bar = st.progress(0)
|
| 88 |
+
status_text = st.empty()
|
| 89 |
+
|
| 90 |
+
for percent_complete in range(100):
|
| 91 |
+
time.sleep(0.01)
|
| 92 |
+
progress_bar.progress(percent_complete + 1)
|
| 93 |
+
status_text.text(f"正在提取LBP纹理、频域伪影及深度语义... {percent_complete + 1}%")
|
| 94 |
+
|
| 95 |
+
from image_module import analyze_image, load_deep_image_model, generate_image_heatmap
|
| 96 |
+
|
| 97 |
+
result = analyze_image(uploaded_file)
|
| 98 |
+
|
| 99 |
+
model, device = load_deep_image_model()
|
| 100 |
+
# 图像模块内部我们已经处理过 seek(0),所以这里可以直接传
|
| 101 |
+
heatmap_img = generate_image_heatmap(uploaded_file, model, device)
|
| 102 |
+
heatmap_placeholder.image(
|
| 103 |
+
heatmap_img,
|
| 104 |
+
caption="🔴 红色区域为模型判定的高可疑伪造区域",
|
| 105 |
+
use_container_width=True
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
status_text.text("特征提取完成")
|
| 109 |
+
|
| 110 |
+
st.subheader("检测报告")
|
| 111 |
+
final_prob = result['final_probability']
|
| 112 |
+
|
| 113 |
+
if final_prob >= high_risk_threshold:
|
| 114 |
+
st.error(f"高度疑似AI生成图像(生成概率:{final_prob * 100:.1f}%)")
|
| 115 |
+
elif warning_threshold <= final_prob < high_risk_threshold:
|
| 116 |
+
st.warning(f"可疑图像,可能经过AI修图或局部生成(概率:{final_prob * 100:.1f}%)")
|
| 117 |
+
else:
|
| 118 |
+
st.success(f"真实图像,未检出明显生成痕迹(概率:{final_prob * 100:.1f}%)")
|
| 119 |
+
|
| 120 |
+
st.progress(float(final_prob))
|
| 121 |
+
with st.expander("展开底层特征参数", expanded=True):
|
| 122 |
+
st.info(
|
| 123 |
+
f"**双轨特征得分:**\n\n"
|
| 124 |
+
f"- 传统��理特征异常度:{result['traditional_score'] * 100:.1f}%\n"
|
| 125 |
+
f"- MobileNetV2 深度特征:{result['deep_score'] * 100:.1f}%"
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
# ----------------- 文本检测 -----------------
|
| 129 |
+
elif file_type in ['txt', 'docx']:
|
| 130 |
+
text_content = ""
|
| 131 |
+
uploaded_file.seek(0) # 核心修复:倒带文件指针
|
| 132 |
+
if file_type == 'txt':
|
| 133 |
+
text_content = uploaded_file.read().decode("utf-8")
|
| 134 |
+
elif file_type == 'docx':
|
| 135 |
+
import docx
|
| 136 |
+
doc = docx.Document(uploaded_file)
|
| 137 |
+
text_content = "\n".join([para.text for para in doc.paragraphs])
|
| 138 |
+
|
| 139 |
+
with col_content:
|
| 140 |
+
st.subheader("原始文本内容")
|
| 141 |
+
text_placeholder = st.empty()
|
| 142 |
+
text_placeholder.text_area("提取的文本", text_content, height=350)
|
| 143 |
+
|
| 144 |
+
with col_result:
|
| 145 |
+
st.subheader("⚙️ 语义连贯性分析中...")
|
| 146 |
+
with st.spinner("正在计算困惑度、句法复杂度及BERT深度特征..."):
|
| 147 |
+
from text_module import analyze_text, get_custom_text_model, generate_text_highlight_html
|
| 148 |
+
|
| 149 |
+
result = analyze_text(text_content)
|
| 150 |
+
|
| 151 |
+
tokenizer, model = get_custom_text_model()
|
| 152 |
+
highlighted_html = generate_text_highlight_html(text_content, tokenizer, model)
|
| 153 |
+
text_placeholder.markdown("**🔥 AI痕迹逐句高亮(黄色为高风险句式)**", unsafe_allow_html=True)
|
| 154 |
+
text_placeholder.markdown(highlighted_html, unsafe_allow_html=True)
|
| 155 |
+
|
| 156 |
+
st.subheader("检测报告")
|
| 157 |
+
final_prob = result['final_probability']
|
| 158 |
+
|
| 159 |
+
if final_prob >= high_risk_threshold:
|
| 160 |
+
st.error(f"高度疑似大语言模型生成文本(生成概率:{final_prob * 100:.1f}%)")
|
| 161 |
+
elif warning_threshold <= final_prob < high_risk_threshold:
|
| 162 |
+
st.warning(f"可疑文本,可能经AI润色或拼接(概率:{final_prob * 100:.1f}%)")
|
| 163 |
+
else:
|
| 164 |
+
st.success(f"真实人类写作风格,低风险(概率:{final_prob * 100:.1f}%)")
|
| 165 |
+
|
| 166 |
+
st.progress(float(final_prob))
|
| 167 |
+
with st.expander("展开底层特征参数", expanded=True):
|
| 168 |
+
st.info(
|
| 169 |
+
f"**多维度文本特征:**\n\n"
|
| 170 |
+
f"- 统计学风格异常度:{result['stat_score'] * 100:.1f}%\n"
|
| 171 |
+
f"- DistilBERT 语义鉴别得分:{result['deep_score'] * 100:.1f}%\n"
|
| 172 |
+
f"- 补充指标:{result['details']}"
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
# ----------------- 视频检测 -----------------
|
| 176 |
+
elif file_type in ['mp4', 'avi', 'mov']:
|
| 177 |
+
with col_content:
|
| 178 |
+
st.subheader("视频内容预览")
|
| 179 |
+
st.video(uploaded_file)
|
| 180 |
+
|
| 181 |
+
with col_result:
|
| 182 |
+
st.subheader("⚙️ 时空特征提取中...")
|
| 183 |
+
with st.spinner("正在进行关键帧抽帧、光流计算及音频频谱分析..."):
|
| 184 |
+
temp_video_path = f"temp_uploaded_video.{file_type}"
|
| 185 |
+
with open(temp_video_path, "wb") as f:
|
| 186 |
+
uploaded_file.seek(0) # 核心修复:倒带文件指针
|
| 187 |
+
f.write(uploaded_file.read())
|
| 188 |
+
|
| 189 |
+
from video_module import analyze_video
|
| 190 |
+
result = analyze_video(temp_video_path)
|
| 191 |
+
|
| 192 |
+
if os.path.exists(temp_video_path):
|
| 193 |
+
os.remove(temp_video_path)
|
| 194 |
+
|
| 195 |
+
if "error" in result:
|
| 196 |
+
st.error(result["error"])
|
| 197 |
+
else:
|
| 198 |
+
st.subheader("检测报告")
|
| 199 |
+
final_prob = result['avg_probability']
|
| 200 |
+
|
| 201 |
+
if final_prob >= high_risk_threshold:
|
| 202 |
+
st.error(f"整体视频高度疑似AI生成(综合概率:{final_prob * 100:.1f}%)")
|
| 203 |
+
elif warning_threshold <= final_prob < high_risk_threshold:
|
| 204 |
+
st.warning(f"视频存疑,存在异常帧或拼接痕迹(概率:{final_prob * 100:.1f}%)")
|
| 205 |
+
else:
|
| 206 |
+
st.success(f"未发现明显时序伪造痕迹,低风险(概率:{final_prob * 100:.1f}%)")
|
| 207 |
+
|
| 208 |
+
st.progress(float(final_prob))
|
| 209 |
+
with st.expander("展开抽帧分析详情", expanded=True):
|
| 210 |
+
st.info(
|
| 211 |
+
f"**视频物理特征:**\n\n"
|
| 212 |
+
f"- 总帧数:{result['total_frames']} 帧 (帧率 {result['fps']:.1f} fps)\n"
|
| 213 |
+
f"- 关键帧采样数:{result['sampled_frames']} 帧\n"
|
| 214 |
+
f"- 单帧最高风险值:{result['max_probability'] * 100:.1f}%"
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
# ==========================================
|
| 218 |
+
# 模式二:多模态联合检测
|
| 219 |
+
# ==========================================
|
| 220 |
+
elif "多模态联合检测" in work_mode:
|
| 221 |
+
st.subheader("🔗 跨模态联合检测场景")
|
| 222 |
+
st.markdown(
|
| 223 |
+
"模拟真实审核场景:同时检测**图像/视频**与**配套文本**,通过决策级加权融合输出综合AI生成概率。"
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
# 初始化session_state记忆体(用于自动提取的文本)
|
| 227 |
+
if 'auto_text' not in st.session_state:
|
| 228 |
+
st.session_state['auto_text'] = ""
|
| 229 |
+
|
| 230 |
+
col_media, col_text = st.columns(2, gap="large")
|
| 231 |
+
|
| 232 |
+
with col_media:
|
| 233 |
+
multi_media = st.file_uploader(
|
| 234 |
+
"🖼️ / 🎞️ 第一步:上传媒体内容(图片或短视频)",
|
| 235 |
+
type=["jpg", "png", "jpeg", "mp4", "avi", "mov"],
|
| 236 |
+
key="multi_media"
|
| 237 |
+
)
|
| 238 |
+
file_type = ""
|
| 239 |
+
if multi_media:
|
| 240 |
+
file_type = multi_media.name.split('.')[-1].lower()
|
| 241 |
+
if file_type in ['mp4', 'avi', 'mov']:
|
| 242 |
+
st.video(multi_media)
|
| 243 |
+
|
| 244 |
+
# 智能语音提取按钮
|
| 245 |
+
if st.button("🎙️ 自动从视频提取语音转文字", use_container_width=True):
|
| 246 |
+
with st.spinner("正在加载Whisper模型并转录音频(若视频较长请耐心等待数分钟)..."):
|
| 247 |
+
try:
|
| 248 |
+
from moviepy import VideoFileClip
|
| 249 |
+
import whisper
|
| 250 |
+
|
| 251 |
+
# 1. 保存临时视频
|
| 252 |
+
temp_vid = "temp_for_audio.mp4"
|
| 253 |
+
with open(temp_vid, "wb") as f:
|
| 254 |
+
multi_media.seek(0)
|
| 255 |
+
f.write(multi_media.read())
|
| 256 |
+
|
| 257 |
+
# 2. 读取并剥离音频
|
| 258 |
+
temp_audio = "temp_audio.wav"
|
| 259 |
+
my_clip = VideoFileClip(temp_vid)
|
| 260 |
+
|
| 261 |
+
# 【新增防护】检查视频到底有没有声音!
|
| 262 |
+
if my_clip.audio is None:
|
| 263 |
+
st.error("❌ 提取失败:检测到该视频没有声音轨道!")
|
| 264 |
+
else:
|
| 265 |
+
my_clip.audio.write_audiofile(temp_audio, logger=None)
|
| 266 |
+
my_clip.close()
|
| 267 |
+
|
| 268 |
+
# 3. 语音识别
|
| 269 |
+
model = load_whisper_model()
|
| 270 |
+
result = model.transcribe(
|
| 271 |
+
temp_audio,
|
| 272 |
+
language="zh",
|
| 273 |
+
initial_prompt="以下是一段标准的简体中文普通话录音。",
|
| 274 |
+
fp16=False
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
st.session_state['auto_text'] = result["text"]
|
| 278 |
+
st.success("✅ 提取成功!")
|
| 279 |
+
time.sleep(1) # 停留1秒让用户看到成功提示
|
| 280 |
+
st.rerun()
|
| 281 |
+
|
| 282 |
+
except Exception as e:
|
| 283 |
+
# 【新增防护】无论出什么错,直接弹在网页上!
|
| 284 |
+
st.error(f"❌ 提取失败,底层报错信息:{str(e)}")
|
| 285 |
+
st.info(
|
| 286 |
+
"💡 提示:如果看到 'ffprobe' 或 'ffmpeg' 等字眼,请确保系统已通过 conda 安装了 ffmpeg。")
|
| 287 |
+
|
| 288 |
+
finally:
|
| 289 |
+
# 【新增防护】哪怕中途崩溃,也要把占硬盘的临时文件删掉
|
| 290 |
+
if os.path.exists(temp_vid):
|
| 291 |
+
try:
|
| 292 |
+
os.remove(temp_vid)
|
| 293 |
+
except:
|
| 294 |
+
pass
|
| 295 |
+
if os.path.exists(temp_audio):
|
| 296 |
+
try:
|
| 297 |
+
os.remove(temp_audio)
|
| 298 |
+
except:
|
| 299 |
+
pass
|
| 300 |
+
else:
|
| 301 |
+
st.image(multi_media, use_container_width=True)
|
| 302 |
+
|
| 303 |
+
with col_text:
|
| 304 |
+
multi_txt = st.text_area(
|
| 305 |
+
"📝 第二步:输入配套文本(或点击左侧自动提取)",
|
| 306 |
+
value=st.session_state['auto_text'],
|
| 307 |
+
height=200,
|
| 308 |
+
key="multi_txt"
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
# 联合检测按钮
|
| 312 |
+
if multi_media and multi_txt:
|
| 313 |
+
if st.button("🔍 启动跨模态融合鉴别", type="primary", use_container_width=True):
|
| 314 |
+
st.divider()
|
| 315 |
+
st.subheader("📊 联合检测报告")
|
| 316 |
+
|
| 317 |
+
from text_module import analyze_text
|
| 318 |
+
|
| 319 |
+
with st.spinner("并行调用视觉鉴别引擎与文本鉴别引擎..."):
|
| 320 |
+
file_type = multi_media.name.split('.')[-1].lower()
|
| 321 |
+
p_media = 0.0
|
| 322 |
+
media_label = ""
|
| 323 |
+
|
| 324 |
+
if file_type in ['mp4', 'avi', 'mov']:
|
| 325 |
+
from video_module import analyze_video
|
| 326 |
+
temp_path = f"temp_multi_video.{file_type}"
|
| 327 |
+
with open(temp_path, "wb") as f:
|
| 328 |
+
multi_media.seek(0) # 核心修复:倒带文件指针
|
| 329 |
+
f.write(multi_media.read())
|
| 330 |
+
res_media = analyze_video(temp_path)
|
| 331 |
+
p_media = res_media.get('avg_probability', 0)
|
| 332 |
+
media_label = "🎞️ 视频维度AI生成概率"
|
| 333 |
+
if os.path.exists(temp_path):
|
| 334 |
+
os.remove(temp_path)
|
| 335 |
+
else:
|
| 336 |
+
from image_module import analyze_image
|
| 337 |
+
res_media = analyze_image(multi_media)
|
| 338 |
+
p_media = res_media['final_probability']
|
| 339 |
+
media_label = "🖼️ 图像维度AI生成概率"
|
| 340 |
+
|
| 341 |
+
# 文本检测
|
| 342 |
+
res_txt = analyze_text(multi_txt)
|
| 343 |
+
p_txt = res_txt['final_probability']
|
| 344 |
+
|
| 345 |
+
# 融合权重(视觉60%,文本40%)
|
| 346 |
+
joint_prob = (p_media * 0.60) + (p_txt * 0.40)
|
| 347 |
+
|
| 348 |
+
# 展示三指标
|
| 349 |
+
col_d1, col_d2, col_d3 = st.columns(3)
|
| 350 |
+
col_d1.metric(label=media_label, value=f"{p_media * 100:.1f}%")
|
| 351 |
+
col_d2.metric(label="📝 文本维度AI生成概率", value=f"{p_txt * 100:.1f}%")
|
| 352 |
+
col_d3.metric(
|
| 353 |
+
label="🔗 多模态综合判定概率",
|
| 354 |
+
value=f"{joint_prob * 100:.1f}%",
|
| 355 |
+
delta="加权融合 (视觉0.6 + 文本0.4)",
|
| 356 |
+
delta_color="off"
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
st.progress(float(joint_prob))
|
| 360 |
+
|
| 361 |
+
# 综合判定
|
| 362 |
+
if joint_prob >= high_risk_threshold:
|
| 363 |
+
st.error("🚨 **联合研判结论:高度疑似AI生成的多模态内容**(图像/视频与文本均呈现显著生成特征)")
|
| 364 |
+
elif warning_threshold <= joint_prob < high_risk_threshold:
|
| 365 |
+
st.warning("⚠️ **联合研判结论:内容存疑**,可能存在局部AI修改或跨模态不一致,建议人工复核")
|
| 366 |
+
else:
|
| 367 |
+
st.success("✅ **联合研判结论:内容安全**,未见明显多模态伪造痕迹")
|
download_data.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datasets import load_dataset
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
print("正在连接 Hugging Face 并提取高清数据集...")
|
| 5 |
+
dataset = load_dataset("Parveshiiii/AI-vs-Real", split="train", verification_mode="no_checks")
|
| 6 |
+
|
| 7 |
+
label_col = 'binary_label'
|
| 8 |
+
print(f"✅ 成功锁定标签列:'{label_col}'\n")
|
| 9 |
+
|
| 10 |
+
os.makedirs("./data/real_batch", exist_ok=True)
|
| 11 |
+
os.makedirs("./data/ai_batch", exist_ok=True)
|
| 12 |
+
|
| 13 |
+
# 设置我们想要的配额
|
| 14 |
+
target_count = 2000
|
| 15 |
+
real_saved = 0
|
| 16 |
+
ai_saved = 0
|
| 17 |
+
|
| 18 |
+
print(f"开始精准抓取:目标 {target_count}张真实图 + {target_count}张AI图...")
|
| 19 |
+
|
| 20 |
+
# 遍历整个数据集,直到两个配额都装满
|
| 21 |
+
for item in dataset:
|
| 22 |
+
img = item['image']
|
| 23 |
+
label = item[label_col]
|
| 24 |
+
|
| 25 |
+
# 工业级防御:统一转为 RGB 格式
|
| 26 |
+
if img.mode != 'RGB':
|
| 27 |
+
img = img.convert('RGB')
|
| 28 |
+
|
| 29 |
+
# 分流并计数
|
| 30 |
+
if label == 1 and real_saved < target_count:
|
| 31 |
+
img.save(f"./data/real_batch/real_hd_{real_saved}.jpg")
|
| 32 |
+
real_saved += 1
|
| 33 |
+
|
| 34 |
+
elif label == 0 and ai_saved < target_count:
|
| 35 |
+
img.save(f"./data/ai_batch/ai_hd_{ai_saved}.jpg")
|
| 36 |
+
ai_saved += 1
|
| 37 |
+
|
| 38 |
+
# 每抓够 100 张,在终端汇报一下进度,让你心里有底
|
| 39 |
+
if (real_saved + ai_saved) % 100 == 0:
|
| 40 |
+
print(f"当前进度 -> 真实图: {real_saved}/{target_count} | AI图: {ai_saved}/{target_count}")
|
| 41 |
+
|
| 42 |
+
# 如果两边的配额都满了,就停止遍历
|
| 43 |
+
if real_saved >= target_count and ai_saved >= target_count:
|
| 44 |
+
break
|
| 45 |
+
|
| 46 |
+
print("\n🎉 完美收工!这次两个文件夹绝对都塞得满满当当了!")
|
download_text_data.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datasets import load_dataset
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
print("正在连接 Hugging Face 拉取 HC3 中文语料库...")
|
| 6 |
+
# 使用 verification_mode="no_checks" 防止部分网络下元数据校验报错
|
| 7 |
+
dataset = load_dataset("Hello-SimpleAI/HC3-Chinese", name="all", split="train", verification_mode="no_checks", trust_remote_code=True)
|
| 8 |
+
|
| 9 |
+
human_texts = []
|
| 10 |
+
ai_texts = []
|
| 11 |
+
target_count = 500 # 咱们先各抓 500 条用来快速跑通流程
|
| 12 |
+
|
| 13 |
+
print(f"开始清洗数据,目标:提取 {target_count}条人类文本 + {target_count}条AI文本...")
|
| 14 |
+
|
| 15 |
+
for item in dataset:
|
| 16 |
+
# 提取人类回答 (剔除太短的废话)
|
| 17 |
+
if len(item['human_answers']) > 0 and len(item['human_answers'][0]) > 20:
|
| 18 |
+
if len(human_texts) < target_count:
|
| 19 |
+
human_texts.append(item['human_answers'][0])
|
| 20 |
+
|
| 21 |
+
# 提取 ChatGPT 回答
|
| 22 |
+
if len(item['chatgpt_answers']) > 0 and len(item['chatgpt_answers'][0]) > 20:
|
| 23 |
+
if len(ai_texts) < target_count:
|
| 24 |
+
ai_texts.append(item['chatgpt_answers'][0])
|
| 25 |
+
|
| 26 |
+
if len(human_texts) >= target_count and len(ai_texts) >= target_count:
|
| 27 |
+
break
|
| 28 |
+
|
| 29 |
+
# 制作结构化的 DataFrame (标签规则统一:0代表AI,1代表真实)
|
| 30 |
+
df_human = pd.DataFrame({'text': human_texts, 'label': 1})
|
| 31 |
+
df_ai = pd.DataFrame({'text': ai_texts, 'label': 0})
|
| 32 |
+
|
| 33 |
+
# 合并并打乱顺序 (Shuffle)
|
| 34 |
+
df_all = pd.concat([df_human, df_ai]).sample(frac=1, random_state=42).reset_index(drop=True)
|
| 35 |
+
|
| 36 |
+
# 确保 data 文件夹存在
|
| 37 |
+
os.makedirs("./data", exist_ok=True)
|
| 38 |
+
csv_path = "./data/text_dataset.csv"
|
| 39 |
+
df_all.to_csv(csv_path, index=False, encoding='utf-8')
|
| 40 |
+
|
| 41 |
+
print(f"✅ 语料库构建完毕!已保存为 {csv_path} (共 {len(df_all)} 条数据)")
|
| 42 |
+
print("你可以打开这个 csv 文件看看,里面的文本非常有意思!")
|
image_module.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cv2
|
| 2 |
+
import numpy as np
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn as nn
|
| 5 |
+
from torchvision import models, transforms
|
| 6 |
+
from skimage import feature
|
| 7 |
+
from scipy.fftpack import dct
|
| 8 |
+
from PIL import Image, ImageOps
|
| 9 |
+
import streamlit as st
|
| 10 |
+
|
| 11 |
+
# 引入热力图相关库
|
| 12 |
+
from pytorch_grad_cam import GradCAM
|
| 13 |
+
from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# ==========================================
|
| 17 |
+
# 辅助函数:智能读取图像(修正 EXIF 方向)
|
| 18 |
+
# ==========================================
|
| 19 |
+
def get_pil_image(image_input):
|
| 20 |
+
if isinstance(image_input, str):
|
| 21 |
+
img = Image.open(image_input)
|
| 22 |
+
else:
|
| 23 |
+
image_input.seek(0)
|
| 24 |
+
img = Image.open(image_input)
|
| 25 |
+
|
| 26 |
+
# 强制应用隐藏的 EXIF 旋转信息,还原真实方向
|
| 27 |
+
img = ImageOps.exif_transpose(img)
|
| 28 |
+
return img.convert('RGB')
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def get_cv_image(image_input):
|
| 32 |
+
pil_img = get_pil_image(image_input)
|
| 33 |
+
# 保证 OpenCV 提取物理特征时,方向与 PIL 绝对一致
|
| 34 |
+
return cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ==========================================
|
| 38 |
+
# 第一部分:传统物理特征检测 (权重 15%)
|
| 39 |
+
# ==========================================
|
| 40 |
+
def extract_traditional_features(image_input):
|
| 41 |
+
img = get_cv_image(image_input)
|
| 42 |
+
if img is None:
|
| 43 |
+
return 0.0
|
| 44 |
+
|
| 45 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 46 |
+
|
| 47 |
+
# 1. LBP 局部二值模式
|
| 48 |
+
lbp = feature.local_binary_pattern(gray, P=8, R=1, method="uniform")
|
| 49 |
+
hist, _ = np.histogram(lbp.ravel(), bins=np.arange(0, 11), density=True)
|
| 50 |
+
lbp_entropy = -np.sum(hist * np.log2(hist + 1e-7))
|
| 51 |
+
|
| 52 |
+
# 2. DCT 离散余弦变换
|
| 53 |
+
dct_data = dct(dct(gray.T, norm='ortho').T, norm='ortho')
|
| 54 |
+
high_freq = np.sum(np.abs(dct_data[10:, 10:]))
|
| 55 |
+
total_energy = np.sum(np.abs(dct_data))
|
| 56 |
+
dct_ratio = high_freq / (total_energy + 1e-7)
|
| 57 |
+
|
| 58 |
+
# 3. FFT 快速傅里叶变换
|
| 59 |
+
f = np.fft.fft2(gray)
|
| 60 |
+
fshift = np.fft.fftshift(f)
|
| 61 |
+
magnitude = 20 * np.log(np.abs(fshift) + 1)
|
| 62 |
+
h, w = magnitude.shape
|
| 63 |
+
center_h, center_w = h // 2, w // 2
|
| 64 |
+
top_left = magnitude[0:center_h, 0:center_w]
|
| 65 |
+
bottom_right = np.flip(magnitude[center_h + (h % 2):, center_w + (w % 2):])
|
| 66 |
+
|
| 67 |
+
if top_left.shape != bottom_right.shape:
|
| 68 |
+
bottom_right = cv2.resize(bottom_right, (top_left.shape[1], top_left.shape[0]))
|
| 69 |
+
|
| 70 |
+
fft_sym_error = np.mean(np.abs(top_left - bottom_right))
|
| 71 |
+
|
| 72 |
+
score = 0.0
|
| 73 |
+
if lbp_entropy > 3.6: score += 0.3
|
| 74 |
+
if dct_ratio < 0.985: score += 0.1
|
| 75 |
+
if fft_sym_error > 13.8: score += 0.1
|
| 76 |
+
|
| 77 |
+
return min(score, 1.0)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ==========================================
|
| 81 |
+
# 第二部分:深度模型缓存与提取
|
| 82 |
+
# ==========================================
|
| 83 |
+
@st.cache_resource
|
| 84 |
+
def load_deep_image_model():
|
| 85 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 86 |
+
model = models.mobilenet_v2(weights=None)
|
| 87 |
+
num_ftrs = model.classifier[1].in_features
|
| 88 |
+
model.classifier[1] = nn.Linear(num_ftrs, 2)
|
| 89 |
+
|
| 90 |
+
# 加载自己炼丹生成的权重文件
|
| 91 |
+
model.load_state_dict(torch.load('mobilenet_finetuned.pth', map_location=device))
|
| 92 |
+
model = model.to(device)
|
| 93 |
+
model.eval()
|
| 94 |
+
|
| 95 |
+
return model, device
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def extract_deep_features(image_input, model, device):
|
| 99 |
+
pil_img = get_pil_image(image_input)
|
| 100 |
+
transform = transforms.Compose([
|
| 101 |
+
transforms.Resize((224, 224)),
|
| 102 |
+
transforms.ToTensor(),
|
| 103 |
+
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
|
| 104 |
+
])
|
| 105 |
+
input_tensor = transform(pil_img).unsqueeze(0).to(device)
|
| 106 |
+
|
| 107 |
+
with torch.no_grad():
|
| 108 |
+
outputs = model(input_tensor)
|
| 109 |
+
probs = torch.softmax(outputs, dim=1)
|
| 110 |
+
fake_prob = probs[0][0].item()
|
| 111 |
+
|
| 112 |
+
return fake_prob
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ==========================================
|
| 116 |
+
# 第三部分:多模态加权融合引擎
|
| 117 |
+
# ==========================================
|
| 118 |
+
def analyze_image(image_input):
|
| 119 |
+
trad_score = extract_traditional_features(image_input)
|
| 120 |
+
model, device = load_deep_image_model()
|
| 121 |
+
deep_score = extract_deep_features(image_input, model, device)
|
| 122 |
+
|
| 123 |
+
final_prob = (trad_score * 0.15) + (deep_score * 0.85)
|
| 124 |
+
|
| 125 |
+
return {
|
| 126 |
+
'traditional_score': trad_score,
|
| 127 |
+
'deep_score': deep_score,
|
| 128 |
+
'final_probability': final_prob
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# ==========================================
|
| 133 |
+
# 第四部分:XAI 热力图渲染引擎
|
| 134 |
+
# ==========================================
|
| 135 |
+
def generate_image_heatmap(image_input, model, device):
|
| 136 |
+
"""使用 Grad-CAM 生成热力图,并无损拉伸回原图尺寸,带动态透明度"""
|
| 137 |
+
# ⚠️ 获取原图,绝对没有任何 resize 操作干扰!
|
| 138 |
+
original_pil_img = get_pil_image(image_input)
|
| 139 |
+
orig_width, orig_height = original_pil_img.size
|
| 140 |
+
|
| 141 |
+
transform = transforms.Compose([
|
| 142 |
+
transforms.Resize((224, 224)),
|
| 143 |
+
transforms.ToTensor(),
|
| 144 |
+
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
|
| 145 |
+
])
|
| 146 |
+
input_tensor = transform(original_pil_img).unsqueeze(0).to(device)
|
| 147 |
+
|
| 148 |
+
target_layers = [model.features[-1]]
|
| 149 |
+
|
| 150 |
+
try:
|
| 151 |
+
with GradCAM(model=model, target_layers=target_layers) as cam:
|
| 152 |
+
targets = [ClassifierOutputTarget(0)]
|
| 153 |
+
# 跑出 224x224 的 0~1 原始热力图矩阵
|
| 154 |
+
grayscale_cam_224 = cam(input_tensor=input_tensor, targets=targets)[0, :]
|
| 155 |
+
|
| 156 |
+
# 将 224x224 的热力图拉伸回真实的高清原图宽高!
|
| 157 |
+
grayscale_cam_resized = cv2.resize(grayscale_cam_224, (orig_width, orig_height))
|
| 158 |
+
|
| 159 |
+
heatmap_color = cv2.applyColorMap(np.uint8(255 * grayscale_cam_resized), cv2.COLORMAP_JET)
|
| 160 |
+
heatmap_color = cv2.cvtColor(heatmap_color, cv2.COLOR_BGR2RGB)
|
| 161 |
+
heatmap_color = np.float32(heatmap_color) / 255.0
|
| 162 |
+
|
| 163 |
+
orig_img_array = np.array(original_pil_img, dtype=np.float32) / 255.0
|
| 164 |
+
|
| 165 |
+
alpha = grayscale_cam_resized[..., np.newaxis]
|
| 166 |
+
alpha = np.power(alpha, 1.5) * 0.65
|
| 167 |
+
|
| 168 |
+
visualization = orig_img_array * (1 - alpha) + heatmap_color * alpha
|
| 169 |
+
return np.uint8(255 * visualization)
|
| 170 |
+
|
| 171 |
+
except Exception as e:
|
| 172 |
+
return np.array(original_pil_img)
|
mobilenet_finetuned.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:22343e127cd0d6a43a5cb78176171b808a3956cd8dfac8db799b77f753e9620c
|
| 3 |
+
size 9149899
|
packages.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
ffmpeg
|
requirements.txt
CHANGED
|
@@ -1,3 +1,12 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit==1.32.0
|
| 2 |
+
torch
|
| 3 |
+
torchvision
|
| 4 |
+
transformers
|
| 5 |
+
opencv-python-headless
|
| 6 |
+
moviepy
|
| 7 |
+
openai-whisper
|
| 8 |
+
pytorch-grad-cam
|
| 9 |
+
scikit-image
|
| 10 |
+
scipy
|
| 11 |
+
Pillow
|
| 12 |
+
numpy
|
test.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
st.title("如果能看到这行字,说明环境没问题!")
|
text_module.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import jieba
|
| 2 |
+
import torch
|
| 3 |
+
import re
|
| 4 |
+
import numpy as np
|
| 5 |
+
import streamlit as st
|
| 6 |
+
from transformers import AutoTokenizer, AutoModelForSequenceClassification
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
# ==========================================
|
| 10 |
+
# 第一部分:统计风格特征提取 (占权重 20%)
|
| 11 |
+
# ==========================================
|
| 12 |
+
def extract_text_statistics(text):
|
| 13 |
+
"""
|
| 14 |
+
提取文本的统计学与风格特征。
|
| 15 |
+
"""
|
| 16 |
+
words = list(jieba.cut(text))
|
| 17 |
+
words = [w for w in words if w.strip() and len(w) > 0]
|
| 18 |
+
if len(words) == 0: return 0.0, 0, 0
|
| 19 |
+
|
| 20 |
+
unique_words = set(words)
|
| 21 |
+
richness = len(unique_words) / len(words)
|
| 22 |
+
|
| 23 |
+
sentences = re.split(r'[。!?\n]', text)
|
| 24 |
+
sentences = [s for s in sentences if len(s.strip()) > 0]
|
| 25 |
+
|
| 26 |
+
sentence_lengths = [len(s) for s in sentences]
|
| 27 |
+
avg_sentence_len = np.mean(sentence_lengths) if sentence_lengths else 0
|
| 28 |
+
sentence_len_std = np.std(sentence_lengths) if sentence_lengths else 0
|
| 29 |
+
|
| 30 |
+
score = 0.0
|
| 31 |
+
if richness < 0.55:
|
| 32 |
+
score += 0.5
|
| 33 |
+
if sentence_len_std < 10.0 and avg_sentence_len > 15:
|
| 34 |
+
score += 0.5
|
| 35 |
+
|
| 36 |
+
return min(score, 1.0), richness, sentence_len_std
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ==========================================
|
| 40 |
+
# 第二部分:深度语义特征提取 (换上我们自己微调的专属模型)
|
| 41 |
+
# ==========================================
|
| 42 |
+
@st.cache_resource
|
| 43 |
+
def get_custom_text_model():
|
| 44 |
+
"""加载我们刚刚炼丹微调出来的专属 BERT 模型"""
|
| 45 |
+
# 直接指向你刚刚生成的那个文件夹
|
| 46 |
+
model_path = "./finetuned_text_model"
|
| 47 |
+
tokenizer = AutoTokenizer.from_pretrained(model_path)
|
| 48 |
+
model = AutoModelForSequenceClassification.from_pretrained(model_path)
|
| 49 |
+
model.eval()
|
| 50 |
+
return tokenizer, model
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def extract_deep_text_features(text, tokenizer, model):
|
| 54 |
+
"""使用专属模型进行推理打分"""
|
| 55 |
+
inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=128)
|
| 56 |
+
with torch.no_grad():
|
| 57 |
+
outputs = model(**inputs)
|
| 58 |
+
|
| 59 |
+
# 提取输出并转换为概率分布 (0: AI, 1: 真实)
|
| 60 |
+
logits = outputs.logits
|
| 61 |
+
probs = torch.softmax(logits, dim=1)
|
| 62 |
+
|
| 63 |
+
# 获取它被判定为 AI (标签0) 的概率百分比
|
| 64 |
+
fake_prob = probs[0][0].item()
|
| 65 |
+
return fake_prob
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ==========================================
|
| 69 |
+
# 第三部分:多模态加权融合
|
| 70 |
+
# ==========================================
|
| 71 |
+
def analyze_text(text_content):
|
| 72 |
+
# 1. 统计特征
|
| 73 |
+
stat_score, richness, std_len = extract_text_statistics(text_content)
|
| 74 |
+
|
| 75 |
+
# 2. 深度特征 (加载你的专属模型)
|
| 76 |
+
tokenizer, model = get_custom_text_model()
|
| 77 |
+
deep_score = extract_deep_text_features(text_content, tokenizer, model)
|
| 78 |
+
|
| 79 |
+
# 3. 加权融合 (既然我们自己炼了丹,深度模型变得极强,权重拉高到 80%)
|
| 80 |
+
final_prob = (stat_score * 0.2) + (deep_score * 0.8)
|
| 81 |
+
|
| 82 |
+
return {
|
| 83 |
+
"stat_score": stat_score,
|
| 84 |
+
"deep_score": deep_score,
|
| 85 |
+
"final_probability": final_prob,
|
| 86 |
+
"details": f"词汇丰富度: {richness:.2f} | 句长波动率: {std_len:.1f}"
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def generate_text_highlight_html(text, tokenizer, model):
|
| 91 |
+
"""逐句扫描文本,生成带有高亮背景色的 HTML 代码"""
|
| 92 |
+
# 按照标点符号将文章拆分为句子
|
| 93 |
+
sentences = re.split(r'([。!?\n])', text)
|
| 94 |
+
# 重新把句子和标点拼装起来
|
| 95 |
+
sentences = ["".join(i) for i in zip(sentences[0::2], sentences[1::2] + [""])]
|
| 96 |
+
|
| 97 |
+
html_content = '<div style="line-height: 1.6; font-size: 16px; padding: 10px; border: 1px solid #ddd; border-radius: 5px;">'
|
| 98 |
+
|
| 99 |
+
for s in sentences:
|
| 100 |
+
if len(s.strip()) < 2:
|
| 101 |
+
html_content += s.replace('\n', '<br>')
|
| 102 |
+
continue
|
| 103 |
+
|
| 104 |
+
# 让专属模型给这一句话单独打分
|
| 105 |
+
prob = extract_deep_text_features(s, tokenizer, model)
|
| 106 |
+
|
| 107 |
+
# 只有当 AI 概率大于 50% 时才开始变红,概率越高颜色越深
|
| 108 |
+
if prob > 0.5:
|
| 109 |
+
alpha = (prob - 0.5) * 2 # 映射到 0~1 透明度
|
| 110 |
+
color = f"rgba(255, 75, 75, {alpha:.2f})"
|
| 111 |
+
html_content += f'<span style="background-color: {color}; border-radius: 3px;">{s}</span>'
|
| 112 |
+
else:
|
| 113 |
+
html_content += s
|
| 114 |
+
|
| 115 |
+
html_content += "</div>"
|
| 116 |
+
return html_content
|
train_image_model.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import torch
|
| 3 |
+
import torch.nn as nn
|
| 4 |
+
import torch.optim as optim
|
| 5 |
+
from torchvision import datasets, models, transforms
|
| 6 |
+
from torch.utils.data import DataLoader
|
| 7 |
+
from tqdm import tqdm # 引入了实时进度条神器
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def train_model():
|
| 11 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 12 |
+
print(f"当前使用的计算设备: {device}")
|
| 13 |
+
|
| 14 |
+
if device.type == 'cpu':
|
| 15 |
+
print("⚠️ 警告:当前正在使用 CPU 训练,4000张图片预计每轮需要 15-30 分钟,请保持耐心!")
|
| 16 |
+
|
| 17 |
+
data_transforms = transforms.Compose([
|
| 18 |
+
transforms.Resize((224, 224)),
|
| 19 |
+
transforms.RandomHorizontalFlip(),
|
| 20 |
+
transforms.ToTensor(),
|
| 21 |
+
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
|
| 22 |
+
])
|
| 23 |
+
|
| 24 |
+
data_dir = './data'
|
| 25 |
+
image_dataset = datasets.ImageFolder(data_dir, data_transforms)
|
| 26 |
+
# CPU 训练比较慢,我们把 batch_size 稍微调大一点点到 16
|
| 27 |
+
dataloader = DataLoader(image_dataset, batch_size=16, shuffle=True)
|
| 28 |
+
|
| 29 |
+
print(f"总计训练图片数量: {len(image_dataset)} 张\n")
|
| 30 |
+
|
| 31 |
+
print("正在加载 MobileNetV2 模型...")
|
| 32 |
+
model = models.mobilenet_v2(weights=models.MobileNet_V2_Weights.IMAGENET1K_V1)
|
| 33 |
+
|
| 34 |
+
num_ftrs = model.classifier[1].in_features
|
| 35 |
+
model.classifier[1] = nn.Linear(num_ftrs, 2)
|
| 36 |
+
model = model.to(device)
|
| 37 |
+
|
| 38 |
+
criterion = nn.CrossEntropyLoss()
|
| 39 |
+
optimizer = optim.Adam(model.parameters(), lr=1e-4)
|
| 40 |
+
|
| 41 |
+
# 为了用 CPU 能快点看到结果,我们先设为 3 轮
|
| 42 |
+
num_epochs = 3
|
| 43 |
+
print("\n--- 开始模型微调 ---")
|
| 44 |
+
|
| 45 |
+
for epoch in range(num_epochs):
|
| 46 |
+
model.train()
|
| 47 |
+
running_loss = 0.0
|
| 48 |
+
corrects = 0
|
| 49 |
+
|
| 50 |
+
# 【核心修改】:用 tqdm 包装 dataloader,生成实时进度条
|
| 51 |
+
progress_bar = tqdm(dataloader, desc=f"第 {epoch + 1}/{num_epochs} 轮", leave=False, colour='green')
|
| 52 |
+
|
| 53 |
+
for inputs, labels in progress_bar:
|
| 54 |
+
inputs = inputs.to(device)
|
| 55 |
+
labels = labels.to(device)
|
| 56 |
+
|
| 57 |
+
optimizer.zero_grad()
|
| 58 |
+
outputs = model(inputs)
|
| 59 |
+
_, preds = torch.max(outputs, 1)
|
| 60 |
+
loss = criterion(outputs, labels)
|
| 61 |
+
|
| 62 |
+
loss.backward()
|
| 63 |
+
optimizer.step()
|
| 64 |
+
|
| 65 |
+
running_loss += loss.item() * inputs.size(0)
|
| 66 |
+
corrects += torch.sum(preds == labels.data)
|
| 67 |
+
|
| 68 |
+
# 让进度条实时显示当前的误差值
|
| 69 |
+
progress_bar.set_postfix({'loss': f"{loss.item():.4f}"})
|
| 70 |
+
|
| 71 |
+
epoch_loss = running_loss / len(image_dataset)
|
| 72 |
+
epoch_acc = corrects.double() / len(image_dataset)
|
| 73 |
+
print(f"✅ 第 {epoch + 1}/{num_epochs} 轮完成 | 平均损失: {epoch_loss:.4f} | 准确率: {epoch_acc:.4f}")
|
| 74 |
+
|
| 75 |
+
save_path = 'mobilenet_finetuned.pth'
|
| 76 |
+
torch.save(model.state_dict(), save_path)
|
| 77 |
+
print(f"\n🎉 训练完成!模型权重已保存至: {save_path}")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
if __name__ == '__main__':
|
| 81 |
+
train_model()
|
train_text_model.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import torch
|
| 3 |
+
from torch.utils.data import Dataset, DataLoader
|
| 4 |
+
from transformers import AutoTokenizer, AutoModelForSequenceClassification
|
| 5 |
+
from torch.optim import AdamW
|
| 6 |
+
from tqdm import tqdm
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
# 1. 定义专属的 PyTorch 文本数据集
|
| 10 |
+
class AITextDataset(Dataset):
|
| 11 |
+
def __init__(self, csv_file, tokenizer, max_len=128):
|
| 12 |
+
self.data = pd.read_csv(csv_file)
|
| 13 |
+
self.tokenizer = tokenizer
|
| 14 |
+
self.max_len = max_len
|
| 15 |
+
|
| 16 |
+
def __len__(self):
|
| 17 |
+
return len(self.data)
|
| 18 |
+
|
| 19 |
+
def __getitem__(self, index):
|
| 20 |
+
text = str(self.data.iloc[index, 0])
|
| 21 |
+
label = int(self.data.iloc[index, 1])
|
| 22 |
+
|
| 23 |
+
# 将汉字切成 token 序列
|
| 24 |
+
encoding = self.tokenizer(
|
| 25 |
+
text,
|
| 26 |
+
add_special_tokens=True,
|
| 27 |
+
max_length=self.max_len,
|
| 28 |
+
padding='max_length',
|
| 29 |
+
truncation=True,
|
| 30 |
+
return_attention_mask=True,
|
| 31 |
+
return_tensors='pt',
|
| 32 |
+
)
|
| 33 |
+
return {
|
| 34 |
+
'input_ids': encoding['input_ids'].flatten(),
|
| 35 |
+
'attention_mask': encoding['attention_mask'].flatten(),
|
| 36 |
+
'labels': torch.tensor(label, dtype=torch.long)
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def train_text():
|
| 41 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 42 |
+
print(f"💻 当前计算设备: {device}")
|
| 43 |
+
if device.type == 'cpu':
|
| 44 |
+
print("⚠️ 警告:当前使用 CPU 炼丹。NLP模型参数量巨大,这可能需要一些时间,请耐心等待...")
|
| 45 |
+
|
| 46 |
+
print("正在加载预训练的中文 BERT 分词器与模型权重...")
|
| 47 |
+
|
| 48 |
+
# 【核心修复】:换成了官方真实存在、最经典的 bert-base-chinese
|
| 49 |
+
model_name = "bert-base-chinese"
|
| 50 |
+
tokenizer = AutoTokenizer.from_pretrained(model_name)
|
| 51 |
+
model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=2)
|
| 52 |
+
model = model.to(device)
|
| 53 |
+
|
| 54 |
+
print("正在封装数据...")
|
| 55 |
+
dataset = AITextDataset('./data/text_dataset.csv', tokenizer, max_len=128)
|
| 56 |
+
# 批量大小设为 8,防止 CPU 内存吃紧
|
| 57 |
+
dataloader = DataLoader(dataset, batch_size=8, shuffle=True)
|
| 58 |
+
|
| 59 |
+
optimizer = AdamW(model.parameters(), lr=2e-5)
|
| 60 |
+
|
| 61 |
+
epochs = 1
|
| 62 |
+
|
| 63 |
+
print("\n🚀 --- 开始文本模型微调 ---")
|
| 64 |
+
model.train()
|
| 65 |
+
|
| 66 |
+
for epoch in range(epochs):
|
| 67 |
+
progress_bar = tqdm(dataloader, desc=f"第 {epoch + 1}/{epochs} 轮", leave=True, colour='blue')
|
| 68 |
+
running_loss = 0.0
|
| 69 |
+
|
| 70 |
+
for batch in progress_bar:
|
| 71 |
+
optimizer.zero_grad()
|
| 72 |
+
|
| 73 |
+
input_ids = batch['input_ids'].to(device)
|
| 74 |
+
attention_mask = batch['attention_mask'].to(device)
|
| 75 |
+
labels = batch['labels'].to(device)
|
| 76 |
+
|
| 77 |
+
outputs = model(input_ids, attention_mask=attention_mask, labels=labels)
|
| 78 |
+
loss = outputs.loss
|
| 79 |
+
|
| 80 |
+
loss.backward()
|
| 81 |
+
optimizer.step()
|
| 82 |
+
|
| 83 |
+
running_loss += loss.item()
|
| 84 |
+
progress_bar.set_postfix({'loss': f"{loss.item():.4f}"})
|
| 85 |
+
|
| 86 |
+
print(f"✅ 第 {epoch + 1} 轮完成 | 平均 Loss: {running_loss / len(dataloader):.4f}")
|
| 87 |
+
|
| 88 |
+
# 保存咱们微调后的专属大模型权重
|
| 89 |
+
save_dir = "./finetuned_text_model"
|
| 90 |
+
model.save_pretrained(save_dir)
|
| 91 |
+
tokenizer.save_pretrained(save_dir)
|
| 92 |
+
print(f"\n🎉 炼丹成功!专属的文本鉴别模型已保存在: {save_dir} 文件夹中。")
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
if __name__ == "__main__":
|
| 96 |
+
train_text()
|
tune_thresholds.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import cv2
|
| 3 |
+
import numpy as np
|
| 4 |
+
from image_module import get_lbp_entropy, get_dct_high_freq_energy, get_fft_symmetry_error
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def process_folder(folder_path, label):
|
| 8 |
+
results = []
|
| 9 |
+
print(f"正在分析 [{label}] 样本集: {folder_path}")
|
| 10 |
+
|
| 11 |
+
for filename in os.listdir(folder_path):
|
| 12 |
+
if filename.lower().endswith(('.png', '.jpg', '.jpeg')):
|
| 13 |
+
filepath = os.path.join(folder_path, filename)
|
| 14 |
+
# 读取图片并转换为灰度图
|
| 15 |
+
img = cv2.imdecode(np.fromfile(filepath, dtype=np.uint8), cv2.IMREAD_COLOR)
|
| 16 |
+
if img is None: continue
|
| 17 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 18 |
+
|
| 19 |
+
# 提取特征
|
| 20 |
+
lbp = get_lbp_entropy(gray)
|
| 21 |
+
dct = get_dct_high_freq_energy(gray)
|
| 22 |
+
fft = get_fft_symmetry_error(gray)
|
| 23 |
+
|
| 24 |
+
results.append((lbp, dct, fft))
|
| 25 |
+
|
| 26 |
+
if not results:
|
| 27 |
+
return None
|
| 28 |
+
|
| 29 |
+
results_arr = np.array(results)
|
| 30 |
+
print(f"--- {label} 统计结果 ({len(results)}张) ---")
|
| 31 |
+
print(
|
| 32 |
+
f"LBP 熵 : 平均 {np.mean(results_arr[:, 0]):.3f} | 范围 [{np.min(results_arr[:, 0]):.3f} - {np.max(results_arr[:, 0]):.3f}]")
|
| 33 |
+
print(
|
| 34 |
+
f"DCT 占比: 平均 {np.mean(results_arr[:, 1]):.3f} | 范围 [{np.min(results_arr[:, 1]):.3f} - {np.max(results_arr[:, 1]):.3f}]")
|
| 35 |
+
print(
|
| 36 |
+
f"FFT 误差: 平均 {np.mean(results_arr[:, 2]):.3f} | 范围 [{np.min(results_arr[:, 2]):.3f} - {np.max(results_arr[:, 2]):.3f}]\n")
|
| 37 |
+
return results_arr
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
if __name__ == "__main__":
|
| 41 |
+
# 请在当前目录下新建这两个文件夹,分别放几十张真实的和AI生成的图进去
|
| 42 |
+
real_folder = "./data/real"
|
| 43 |
+
ai_folder = "./data/ai"
|
| 44 |
+
|
| 45 |
+
print("开始执行特征分布寻优...\n")
|
| 46 |
+
process_folder(real_folder, "真实图像")
|
| 47 |
+
process_folder(ai_folder, "AI生成图像")
|
video_module.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cv2
|
| 2 |
+
import numpy as np
|
| 3 |
+
import os
|
| 4 |
+
from PIL import Image
|
| 5 |
+
from image_module import analyze_image
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def analyze_video(video_path, num_samples=10):
|
| 9 |
+
"""
|
| 10 |
+
使用 OpenCV 对视频进行均匀抽帧,并复用图像引擎进行鉴别
|
| 11 |
+
:param video_path: 视频文件的路径
|
| 12 |
+
:param num_samples: 准备抽取的代表性帧数(默认 10 帧)
|
| 13 |
+
"""
|
| 14 |
+
# 1. 打开视频文件
|
| 15 |
+
cap = cv2.VideoCapture(video_path)
|
| 16 |
+
if not cap.isOpened():
|
| 17 |
+
return {"error": "无法打开视频文件"}
|
| 18 |
+
|
| 19 |
+
# 2. 获取视频的基础信息
|
| 20 |
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 21 |
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 22 |
+
|
| 23 |
+
# 3. 计算均匀抽帧的索引 (比如从 300 帧里均匀选 10 个时间点)
|
| 24 |
+
if total_frames < num_samples:
|
| 25 |
+
num_samples = total_frames # 如果视频太短,有几帧抽几帧
|
| 26 |
+
intervals = np.linspace(0, total_frames - 1, num_samples, dtype=int)
|
| 27 |
+
|
| 28 |
+
frame_scores = []
|
| 29 |
+
|
| 30 |
+
# 4. 开始逐帧提取
|
| 31 |
+
for frame_idx in intervals:
|
| 32 |
+
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx) # 跳转到指定帧
|
| 33 |
+
ret, frame = cap.read()
|
| 34 |
+
|
| 35 |
+
if ret:
|
| 36 |
+
# OpenCV 默认读取的是 BGR 格式,我们需要转成正常的 RGB 格式
|
| 37 |
+
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
| 38 |
+
pil_img = Image.fromarray(frame_rgb)
|
| 39 |
+
|
| 40 |
+
# 临时保存为图片文件,喂给咱们之前写好的图像模块
|
| 41 |
+
temp_path = f"temp_frame_{frame_idx}.jpg"
|
| 42 |
+
pil_img.save(temp_path)
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
# 🌟 核心:直接调用咱们炼好的图像鉴别引擎!
|
| 46 |
+
result = analyze_image(temp_path)
|
| 47 |
+
frame_scores.append(result['final_probability'])
|
| 48 |
+
finally:
|
| 49 |
+
# 阅后即焚,清理临时文件
|
| 50 |
+
if os.path.exists(temp_path):
|
| 51 |
+
os.remove(temp_path)
|
| 52 |
+
|
| 53 |
+
cap.release()
|
| 54 |
+
|
| 55 |
+
if not frame_scores:
|
| 56 |
+
return {"error": "未能成功提取任何视频帧"}
|
| 57 |
+
|
| 58 |
+
# 5. 综合计算这 10 张图的得分
|
| 59 |
+
avg_score = np.mean(frame_scores)
|
| 60 |
+
max_score = np.max(frame_scores) # 记录最可疑的一帧
|
| 61 |
+
|
| 62 |
+
return {
|
| 63 |
+
"avg_probability": avg_score,
|
| 64 |
+
"max_probability": max_score,
|
| 65 |
+
"sampled_frames": num_samples,
|
| 66 |
+
"total_frames": total_frames,
|
| 67 |
+
"fps": fps
|
| 68 |
+
}
|