- .idea/.gitignore +5 -0
- .idea/PicExam.iml +12 -0
- .idea/modules.xml +8 -0
- .idea/vcs.xml +6 -0
- Dockerfile +28 -4
- README.md +250 -2
- app.py +534 -4
- example_usage.py +261 -0
- quick_test.py +178 -0
- requirements.txt +8 -0
- start_local.py +104 -0
- static/index.html +552 -0
- test_api.py +195 -0
.idea/.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 默认忽略的文件
|
| 2 |
+
/shelf/
|
| 3 |
+
/workspace.xml
|
| 4 |
+
# 基于编辑器的 HTTP 客户端请求
|
| 5 |
+
/httpRequests/
|
.idea/PicExam.iml
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<module type="WEB_MODULE" version="4">
|
| 3 |
+
<component name="NewModuleRootManager">
|
| 4 |
+
<content url="file://$MODULE_DIR$">
|
| 5 |
+
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
| 6 |
+
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
| 7 |
+
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
| 8 |
+
</content>
|
| 9 |
+
<orderEntry type="inheritedJdk" />
|
| 10 |
+
<orderEntry type="sourceFolder" forTests="false" />
|
| 11 |
+
</component>
|
| 12 |
+
</module>
|
.idea/modules.xml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<project version="4">
|
| 3 |
+
<component name="ProjectModuleManager">
|
| 4 |
+
<modules>
|
| 5 |
+
<module fileurl="file://$PROJECT_DIR$/.idea/PicExam.iml" filepath="$PROJECT_DIR$/.idea/PicExam.iml" />
|
| 6 |
+
</modules>
|
| 7 |
+
</component>
|
| 8 |
+
</project>
|
.idea/vcs.xml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<project version="4">
|
| 3 |
+
<component name="VcsDirectoryMappings">
|
| 4 |
+
<mapping directory="" vcs="Git" />
|
| 5 |
+
</component>
|
| 6 |
+
</project>
|
Dockerfile
CHANGED
|
@@ -1,16 +1,40 @@
|
|
| 1 |
# Read the doc: https://huggingface.co/docs/hub/spaces-sdks-docker
|
| 2 |
-
#
|
| 3 |
|
| 4 |
-
FROM python:3.
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
RUN useradd -m -u 1000 user
|
| 7 |
USER user
|
| 8 |
ENV PATH="/home/user/.local/bin:$PATH"
|
| 9 |
|
|
|
|
| 10 |
WORKDIR /app
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
COPY --chown=user ./requirements.txt requirements.txt
|
| 13 |
-
RUN pip install --no-cache-dir --upgrade
|
|
|
|
| 14 |
|
|
|
|
| 15 |
COPY --chown=user . /app
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# Read the doc: https://huggingface.co/docs/hub/spaces-sdks-docker
|
| 2 |
+
# Dockerfile for Qwen-VL PicExam API with CPU inference optimization
|
| 3 |
|
| 4 |
+
FROM python:3.10-slim
|
| 5 |
|
| 6 |
+
# 安装系统依赖
|
| 7 |
+
RUN apt-get update && apt-get install -y \
|
| 8 |
+
git \
|
| 9 |
+
wget \
|
| 10 |
+
curl \
|
| 11 |
+
build-essential \
|
| 12 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 13 |
+
|
| 14 |
+
# 创建用户
|
| 15 |
RUN useradd -m -u 1000 user
|
| 16 |
USER user
|
| 17 |
ENV PATH="/home/user/.local/bin:$PATH"
|
| 18 |
|
| 19 |
+
# 设置工作目录
|
| 20 |
WORKDIR /app
|
| 21 |
|
| 22 |
+
# 设置环境变量优化内存使用
|
| 23 |
+
ENV PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:512
|
| 24 |
+
ENV TOKENIZERS_PARALLELISM=false
|
| 25 |
+
ENV OMP_NUM_THREADS=4
|
| 26 |
+
ENV MKL_NUM_THREADS=4
|
| 27 |
+
|
| 28 |
+
# 复制并安装 Python 依赖
|
| 29 |
COPY --chown=user ./requirements.txt requirements.txt
|
| 30 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 31 |
+
pip install --no-cache-dir --upgrade -r requirements.txt
|
| 32 |
|
| 33 |
+
# 复制应用代码
|
| 34 |
COPY --chown=user . /app
|
| 35 |
+
|
| 36 |
+
# 暴露端口
|
| 37 |
+
EXPOSE 7860
|
| 38 |
+
|
| 39 |
+
# 启动命令,增加内存和超时配置
|
| 40 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--timeout-keep-alive", "300", "--workers", "1"]
|
README.md
CHANGED
|
@@ -6,7 +6,255 @@ colorTo: red
|
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
license: apache-2.0
|
| 9 |
-
short_description:
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
license: apache-2.0
|
| 9 |
+
short_description: 基于 Qwen-VL 的图像理解 API,支持 16GB 内存 + CPU 推理
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# 🏆 PicExam - Qwen-VL 图像理解 API
|
| 13 |
+
|
| 14 |
+
基于 Qwen2-VL-2B-Instruct 模型的图像理解 API,专门优化用于 16GB 内存 + 纯 CPU 推理环境。
|
| 15 |
+
|
| 16 |
+
## ✨ 特性
|
| 17 |
+
|
| 18 |
+
- 🧠 **智能图像理解**: 基于阿里巴巴 Qwen2-VL-2B-Instruct 模型
|
| 19 |
+
- 💻 **CPU 优化**: 专门针对 CPU 推理进行优化,无需 GPU
|
| 20 |
+
- 🔧 **内存友好**: 适配 16GB 内存环境,包含内存监控和优化
|
| 21 |
+
- 🚀 **易于部署**: 支持本地运行和 Hugging Face Spaces 部署
|
| 22 |
+
- 📝 **多种接口**: 支持文件上传和 base64 图片输入
|
| 23 |
+
- 📊 **实时监控**: 内置内存使用监控和缓存管理
|
| 24 |
+
|
| 25 |
+
## 🛠️ 系统要求
|
| 26 |
+
|
| 27 |
+
- **内存**: 16GB RAM(推荐)
|
| 28 |
+
- **处理器**: 多核 CPU(推荐 4 核以上)
|
| 29 |
+
- **存储**: 至少 10GB 可用空间(用于模型下载)
|
| 30 |
+
- **Python**: 3.10+
|
| 31 |
+
|
| 32 |
+
## 🚀 快速开始
|
| 33 |
+
|
| 34 |
+
### 本地运行
|
| 35 |
+
|
| 36 |
+
1. **克隆项目**
|
| 37 |
+
```bash
|
| 38 |
+
git clone <repository-url>
|
| 39 |
+
cd PicExam
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
2. **安装依赖**
|
| 43 |
+
```bash
|
| 44 |
+
pip install -r requirements.txt
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
3. **启动服务**
|
| 48 |
+
```bash
|
| 49 |
+
python start_local.py
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
或者直接使用 uvicorn:
|
| 53 |
+
```bash
|
| 54 |
+
uvicorn app:app --host 0.0.0.0 --port 7860
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
4. **访问 API**
|
| 58 |
+
- API 服务: http://localhost:7860
|
| 59 |
+
- 交互式文档: http://localhost:7860/docs
|
| 60 |
+
|
| 61 |
+
### Docker 部署
|
| 62 |
+
|
| 63 |
+
```bash
|
| 64 |
+
docker build -t picexam .
|
| 65 |
+
docker run -p 7860:7860 picexam
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
### Hugging Face Spaces 部署
|
| 69 |
+
|
| 70 |
+
1. 将代码推送到 Hugging Face Spaces 仓库
|
| 71 |
+
2. 确保 `README.md` 中的 YAML 配置正确
|
| 72 |
+
3. Spaces 会自动构建和部署
|
| 73 |
+
|
| 74 |
+
## 📖 API 使用说明
|
| 75 |
+
|
| 76 |
+
### 🌐 浏览器访问
|
| 77 |
+
|
| 78 |
+
- **主页面**: http://localhost:7860/ - 显示完整的 API 端点定义和使用方法
|
| 79 |
+
- **Web 界面**: http://localhost:7860/web - 图形化操作界面,支持拖拽上传
|
| 80 |
+
- **API 文档**: http://localhost:7860/docs - 交互式 API 文档 (Swagger UI)
|
| 81 |
+
|
| 82 |
+
### 📡 API 端点
|
| 83 |
+
|
| 84 |
+
#### 1. API 信息获取
|
| 85 |
+
```bash
|
| 86 |
+
curl http://localhost:7860/
|
| 87 |
+
```
|
| 88 |
+
返回完整的 API 端点定义、使用方法和示例
|
| 89 |
+
|
| 90 |
+
#### 2. 健康检查
|
| 91 |
+
```bash
|
| 92 |
+
curl http://localhost:7860/health
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
#### 3. 图片分析(文件上传)
|
| 96 |
+
```bash
|
| 97 |
+
curl -X POST "http://localhost:7860/analyze_image" \
|
| 98 |
+
-F "image=@your_image.jpg" \
|
| 99 |
+
-F "question=请描述这张图片的内容"
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
#### 4. 图片分析(Base64 表单)
|
| 103 |
+
```bash
|
| 104 |
+
curl -X POST "http://localhost:7860/analyze_image_base64" \
|
| 105 |
+
-F "image_base64=data:image/jpeg;base64,/9j/4AAQ..." \
|
| 106 |
+
-F "question=这张图片中有什么?"
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
#### 5. 图片分析(JSON API)⭐ 推荐
|
| 110 |
+
```bash
|
| 111 |
+
curl -X POST "http://localhost:7860/analyze" \
|
| 112 |
+
-H "Content-Type: application/json" \
|
| 113 |
+
-d '{
|
| 114 |
+
"image": "data:image/jpeg;base64,/9j/4AAQ...",
|
| 115 |
+
"prompt": "请详细描述这张图片的内容"
|
| 116 |
+
}'
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
**JSON API 响应格式:**
|
| 120 |
+
```json
|
| 121 |
+
{
|
| 122 |
+
"success": true,
|
| 123 |
+
"prompt": "请详细描述这张图片的内容",
|
| 124 |
+
"response": "这张图片显示了...",
|
| 125 |
+
"processing_time": 8.45,
|
| 126 |
+
"image_info": {
|
| 127 |
+
"size": "1024x768",
|
| 128 |
+
"mode": "RGB",
|
| 129 |
+
"format": "JPEG"
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
#### 6. 内存状态监控
|
| 135 |
+
```bash
|
| 136 |
+
curl http://localhost:7860/memory_status
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
#### 7. 清理缓存
|
| 140 |
+
```bash
|
| 141 |
+
curl -X POST http://localhost:7860/clear_cache
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
### 🐍 Python 调用示例
|
| 145 |
+
|
| 146 |
+
```python
|
| 147 |
+
import requests
|
| 148 |
+
import base64
|
| 149 |
+
|
| 150 |
+
# 1. JSON API 调用(推荐)
|
| 151 |
+
def analyze_image_json(image_base64, prompt):
|
| 152 |
+
response = requests.post('http://localhost:7860/analyze',
|
| 153 |
+
json={
|
| 154 |
+
"image": image_base64,
|
| 155 |
+
"prompt": prompt
|
| 156 |
+
})
|
| 157 |
+
return response.json()
|
| 158 |
+
|
| 159 |
+
# 2. 文件上传调用
|
| 160 |
+
def analyze_image_file(image_path, question):
|
| 161 |
+
with open(image_path, 'rb') as f:
|
| 162 |
+
response = requests.post('http://localhost:7860/analyze_image',
|
| 163 |
+
files={"image": f},
|
| 164 |
+
data={"question": question})
|
| 165 |
+
return response.json()
|
| 166 |
+
|
| 167 |
+
# 使用示例
|
| 168 |
+
result = analyze_image_json("data:image/jpeg;base64,/9j/4AAQ...", "描述这张图片")
|
| 169 |
+
print(result['response'])
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
## 🧪 测试
|
| 173 |
+
|
| 174 |
+
### 快速测试
|
| 175 |
+
```bash
|
| 176 |
+
python quick_test.py
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
### 完整功能测试
|
| 180 |
+
```bash
|
| 181 |
+
python test_api.py
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
### 使用示例演示
|
| 185 |
+
```bash
|
| 186 |
+
python example_usage.py
|
| 187 |
+
```
|
| 188 |
+
|
| 189 |
+
测试包括:
|
| 190 |
+
- ✅ API 信息获取
|
| 191 |
+
- ✅ 健康检查
|
| 192 |
+
- ✅ 内存状态监控
|
| 193 |
+
- ✅ 图片分析(文件上传)
|
| 194 |
+
- ✅ 图片分析(Base64 表单)
|
| 195 |
+
- ✅ 图片分析(JSON API)
|
| 196 |
+
- ✅ 缓存清理
|
| 197 |
+
|
| 198 |
+
## ⚙️ 配置优化
|
| 199 |
+
|
| 200 |
+
### 内存优化设置
|
| 201 |
+
|
| 202 |
+
项目已包含以下内存优化配置:
|
| 203 |
+
|
| 204 |
+
- `torch_dtype=torch.float16`: 使用半精度浮点数
|
| 205 |
+
- `low_cpu_mem_usage=True`: 启用低内存使用模式
|
| 206 |
+
- `use_cache=False`: 禁用 KV 缓存
|
| 207 |
+
- 环境变量优化: `PYTORCH_CUDA_ALLOC_CONF`, `TOKENIZERS_PARALLELISM`
|
| 208 |
+
|
| 209 |
+
### 性能调优
|
| 210 |
+
|
| 211 |
+
- `OMP_NUM_THREADS=4`: 限制 OpenMP 线程数
|
| 212 |
+
- `MKL_NUM_THREADS=4`: 限制 MKL 线程数
|
| 213 |
+
- 单 worker 模式避免内存重复
|
| 214 |
+
|
| 215 |
+
## 📊 性能指标
|
| 216 |
+
|
| 217 |
+
在 16GB 内存环境下的典型性能:
|
| 218 |
+
|
| 219 |
+
- **模型加载时间**: 30-60 秒(首次)
|
| 220 |
+
- **推理时间**: 5-15 秒/图片(取决于 CPU)
|
| 221 |
+
- **内存使用**: 8-12GB(包含模型和系统)
|
| 222 |
+
- **支持图片格式**: JPEG, PNG, WebP 等
|
| 223 |
+
|
| 224 |
+
## 🔧 故障排除
|
| 225 |
+
|
| 226 |
+
### 常见问题
|
| 227 |
+
|
| 228 |
+
1. **内存不足**
|
| 229 |
+
- 关闭其他程序释放内存
|
| 230 |
+
- 使用 `/clear_cache` 接口清理缓存
|
| 231 |
+
|
| 232 |
+
2. **模型下载慢**
|
| 233 |
+
- 配置 Hugging Face 镜像源
|
| 234 |
+
- 使用代理或 VPN
|
| 235 |
+
|
| 236 |
+
3. **推理速度慢**
|
| 237 |
+
- 确保 CPU 有足够核心数
|
| 238 |
+
- 检查系统负载
|
| 239 |
+
|
| 240 |
+
### 日志查看
|
| 241 |
+
|
| 242 |
+
应用使用标准 Python logging,可通过以下方式查看详细日志:
|
| 243 |
+
|
| 244 |
+
```bash
|
| 245 |
+
export PYTHONPATH=.
|
| 246 |
+
python -c "import logging; logging.basicConfig(level=logging.DEBUG)"
|
| 247 |
+
uvicorn app:app --log-level debug
|
| 248 |
+
```
|
| 249 |
+
|
| 250 |
+
## 📄 许可证
|
| 251 |
+
|
| 252 |
+
Apache License 2.0
|
| 253 |
+
|
| 254 |
+
## 🤝 贡献
|
| 255 |
+
|
| 256 |
+
欢迎提交 Issue 和 Pull Request!
|
| 257 |
+
|
| 258 |
+
## 📞 支持
|
| 259 |
+
|
| 260 |
+
如有问题,请在 GitHub Issues 中提出。
|
app.py
CHANGED
|
@@ -1,7 +1,537 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
@app.get("/")
|
| 6 |
-
def
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import torch
|
| 3 |
+
import psutil
|
| 4 |
+
import gc
|
| 5 |
+
import time
|
| 6 |
+
import json
|
| 7 |
+
from fastapi import FastAPI, File, UploadFile, Form, Request
|
| 8 |
+
from fastapi.responses import JSONResponse, FileResponse
|
| 9 |
+
from fastapi.staticfiles import StaticFiles
|
| 10 |
+
from pydantic import BaseModel
|
| 11 |
+
from transformers import Qwen2VLForConditionalGeneration, AutoTokenizer, AutoProcessor
|
| 12 |
+
from qwen_vl_utils import process_vision_info
|
| 13 |
+
from PIL import Image
|
| 14 |
+
import io
|
| 15 |
+
import base64
|
| 16 |
+
import logging
|
| 17 |
|
| 18 |
+
# 配置日志
|
| 19 |
+
logging.basicConfig(level=logging.INFO)
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
# 数据模型
|
| 23 |
+
class AnalyzeRequest(BaseModel):
|
| 24 |
+
image: str # base64 编码的图片
|
| 25 |
+
prompt: str = "请描述这张图片的内容" # 提示词/问题
|
| 26 |
+
|
| 27 |
+
class AnalyzeResponse(BaseModel):
|
| 28 |
+
success: bool
|
| 29 |
+
prompt: str
|
| 30 |
+
response: str
|
| 31 |
+
processing_time: float
|
| 32 |
+
image_info: dict = None
|
| 33 |
+
error: str = None
|
| 34 |
+
|
| 35 |
+
app = FastAPI(title="Qwen-VL PicExam API", description="基于 Qwen2-VL-2B-Instruct 的图像理解 API")
|
| 36 |
+
|
| 37 |
+
# 挂载静态文件
|
| 38 |
+
app.mount("/static", StaticFiles(directory="static"), name="static")
|
| 39 |
+
|
| 40 |
+
# 全局变量存储模型和处理器
|
| 41 |
+
model = None
|
| 42 |
+
processor = None
|
| 43 |
+
tokenizer = None
|
| 44 |
+
|
| 45 |
+
def load_model():
|
| 46 |
+
"""加载 Qwen2-VL-2B-Instruct 模型(CPU 版本,适合 16GB 内存)"""
|
| 47 |
+
global model, processor, tokenizer
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
logger.info("开始加载 Qwen2-VL-2B-Instruct 模型...")
|
| 51 |
+
|
| 52 |
+
model_name = "Qwen/Qwen2-VL-2B-Instruct"
|
| 53 |
+
|
| 54 |
+
# 设置环境变量优化内存使用
|
| 55 |
+
os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "max_split_size_mb:512"
|
| 56 |
+
os.environ["TOKENIZERS_PARALLELISM"] = "false" # 避免分词器并行导致的内存问题
|
| 57 |
+
|
| 58 |
+
# 加载处理器和分词器
|
| 59 |
+
processor = AutoProcessor.from_pretrained(
|
| 60 |
+
model_name,
|
| 61 |
+
trust_remote_code=True
|
| 62 |
+
)
|
| 63 |
+
tokenizer = AutoTokenizer.from_pretrained(
|
| 64 |
+
model_name,
|
| 65 |
+
trust_remote_code=True
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
# 加载模型到 CPU,使用内存优化配置
|
| 69 |
+
model = Qwen2VLForConditionalGeneration.from_pretrained(
|
| 70 |
+
model_name,
|
| 71 |
+
torch_dtype=torch.float16, # 使用 float16 减少内存使用
|
| 72 |
+
device_map="cpu", # 强制使用 CPU
|
| 73 |
+
low_cpu_mem_usage=True, # 低内存使用模式
|
| 74 |
+
trust_remote_code=True,
|
| 75 |
+
# 额外的内存优化选项
|
| 76 |
+
use_cache=False, # 禁用 KV 缓存以节省内存
|
| 77 |
+
attn_implementation="eager", # 使用 eager attention 实现
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# 设置为评估模式
|
| 81 |
+
model.eval()
|
| 82 |
+
|
| 83 |
+
# 清理不必要的内存
|
| 84 |
+
if torch.cuda.is_available():
|
| 85 |
+
torch.cuda.empty_cache()
|
| 86 |
+
|
| 87 |
+
logger.info("模型加载成功!")
|
| 88 |
+
logger.info(f"模型参数数量: {sum(p.numel() for p in model.parameters()) / 1e6:.1f}M")
|
| 89 |
+
|
| 90 |
+
return True
|
| 91 |
+
|
| 92 |
+
except Exception as e:
|
| 93 |
+
logger.error(f"模型加载失败: {str(e)}")
|
| 94 |
+
return False
|
| 95 |
+
|
| 96 |
+
# 启动时加载模型
|
| 97 |
+
@app.on_event("startup")
|
| 98 |
+
async def startup_event():
|
| 99 |
+
"""应用启动时加载模型"""
|
| 100 |
+
success = load_model()
|
| 101 |
+
if not success:
|
| 102 |
+
logger.error("模型加载失败,应用可能无法正常工作")
|
| 103 |
|
| 104 |
@app.get("/")
|
| 105 |
+
def api_documentation():
|
| 106 |
+
"""API 文档和端点说明"""
|
| 107 |
+
return {
|
| 108 |
+
"service": "Qwen-VL PicExam API",
|
| 109 |
+
"description": "基于 Qwen2-VL-2B-Instruct 的图像理解 API,支持 16GB 内存 + CPU 推理",
|
| 110 |
+
"version": "1.0.0",
|
| 111 |
+
"model": "Qwen2-VL-2B-Instruct",
|
| 112 |
+
"status": {
|
| 113 |
+
"service": "running",
|
| 114 |
+
"model_loaded": model is not None,
|
| 115 |
+
"inference_mode": "CPU"
|
| 116 |
+
},
|
| 117 |
+
"endpoints": {
|
| 118 |
+
"GET /": {
|
| 119 |
+
"description": "获取 API 文档和端点信息",
|
| 120 |
+
"response": "JSON 格式的 API 说明"
|
| 121 |
+
},
|
| 122 |
+
"GET /health": {
|
| 123 |
+
"description": "健康检查接口",
|
| 124 |
+
"response": "服务状态信息"
|
| 125 |
+
},
|
| 126 |
+
"GET /web": {
|
| 127 |
+
"description": "Web 界面",
|
| 128 |
+
"response": "HTML 页面,提供图形化操作界面"
|
| 129 |
+
},
|
| 130 |
+
"POST /analyze_image": {
|
| 131 |
+
"description": "分析上传的图片文件",
|
| 132 |
+
"parameters": {
|
| 133 |
+
"image": "图片文件 (multipart/form-data)",
|
| 134 |
+
"question": "关于图片的问题 (可选,默认为描述图片内容)"
|
| 135 |
+
},
|
| 136 |
+
"example": "curl -X POST '/analyze_image' -F 'image=@photo.jpg' -F 'question=这张图片中有什么?'"
|
| 137 |
+
},
|
| 138 |
+
"POST /analyze_image_base64": {
|
| 139 |
+
"description": "分析 base64 编码的图片",
|
| 140 |
+
"parameters": {
|
| 141 |
+
"image_base64": "base64 编码的图片数据",
|
| 142 |
+
"question": "关于图片的问题 (可���)"
|
| 143 |
+
},
|
| 144 |
+
"example": "curl -X POST '/analyze_image_base64' -F 'image_base64=data:image/jpeg;base64,/9j/4AAQ...' -F 'question=描述这张图片'"
|
| 145 |
+
},
|
| 146 |
+
"POST /analyze": {
|
| 147 |
+
"description": "简化的图片分析接口 (JSON 格式)",
|
| 148 |
+
"parameters": {
|
| 149 |
+
"image": "base64 编码的图片数据",
|
| 150 |
+
"prompt": "提示词/问题"
|
| 151 |
+
},
|
| 152 |
+
"example": "curl -X POST '/analyze' -H 'Content-Type: application/json' -d '{\"image\":\"data:image/jpeg;base64,...\",\"prompt\":\"描述图片\"}'"
|
| 153 |
+
},
|
| 154 |
+
"GET /memory_status": {
|
| 155 |
+
"description": "获取内存使用状态",
|
| 156 |
+
"response": "系统内存和模型内存使用情况"
|
| 157 |
+
},
|
| 158 |
+
"POST /clear_cache": {
|
| 159 |
+
"description": "清理内存缓存",
|
| 160 |
+
"response": "缓存清理结果"
|
| 161 |
+
}
|
| 162 |
+
},
|
| 163 |
+
"usage_examples": {
|
| 164 |
+
"curl_file_upload": "curl -X POST 'http://localhost:7860/analyze_image' -F 'image=@your_image.jpg' -F 'question=请描述这张图片'",
|
| 165 |
+
"curl_base64": "curl -X POST 'http://localhost:7860/analyze_image_base64' -F 'image_base64=data:image/jpeg;base64,/9j/4AAQ...' -F 'question=这张图片中有什么?'",
|
| 166 |
+
"curl_json": "curl -X POST 'http://localhost:7860/analyze' -H 'Content-Type: application/json' -d '{\"image\":\"data:image/jpeg;base64,iVBORw0KGgoAAAANSUhEUgAA...\",\"prompt\":\"请详细描述这张图片的内容\"}'"
|
| 167 |
+
},
|
| 168 |
+
"supported_formats": ["JPEG", "PNG", "WebP", "BMP", "GIF"],
|
| 169 |
+
"memory_requirements": "16GB RAM recommended for optimal performance",
|
| 170 |
+
"inference_time": "5-15 seconds per image (depends on CPU)",
|
| 171 |
+
"documentation": "Visit /docs for interactive API documentation"
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
@app.get("/health")
|
| 175 |
+
def health_check():
|
| 176 |
+
"""简单的健康检查接口"""
|
| 177 |
+
return {
|
| 178 |
+
"status": "healthy",
|
| 179 |
+
"service": "Qwen-VL PicExam API",
|
| 180 |
+
"model_loaded": model is not None,
|
| 181 |
+
"timestamp": time.time()
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
@app.post("/analyze_image")
|
| 185 |
+
async def analyze_image(
|
| 186 |
+
image: UploadFile = File(...),
|
| 187 |
+
question: str = Form("请描述这张图片的内容")
|
| 188 |
+
):
|
| 189 |
+
"""
|
| 190 |
+
分析上传的图片并回答问题
|
| 191 |
+
|
| 192 |
+
Args:
|
| 193 |
+
image: 上传的图片文件
|
| 194 |
+
question: 关于图片的问题(默认为描述图片内容)
|
| 195 |
+
|
| 196 |
+
Returns:
|
| 197 |
+
JSON 响应包含分析结果
|
| 198 |
+
"""
|
| 199 |
+
if model is None or processor is None:
|
| 200 |
+
return JSONResponse(
|
| 201 |
+
status_code=503,
|
| 202 |
+
content={"error": "模型未加载,请稍后重试"}
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
try:
|
| 206 |
+
# 读取图片
|
| 207 |
+
image_bytes = await image.read()
|
| 208 |
+
pil_image = Image.open(io.BytesIO(image_bytes))
|
| 209 |
+
|
| 210 |
+
# 确保图片是 RGB 格式
|
| 211 |
+
if pil_image.mode != 'RGB':
|
| 212 |
+
pil_image = pil_image.convert('RGB')
|
| 213 |
+
|
| 214 |
+
# 准备消息格式
|
| 215 |
+
messages = [
|
| 216 |
+
{
|
| 217 |
+
"role": "user",
|
| 218 |
+
"content": [
|
| 219 |
+
{
|
| 220 |
+
"type": "image",
|
| 221 |
+
"image": pil_image,
|
| 222 |
+
},
|
| 223 |
+
{"type": "text", "text": question},
|
| 224 |
+
],
|
| 225 |
+
}
|
| 226 |
+
]
|
| 227 |
+
|
| 228 |
+
# 处理输入
|
| 229 |
+
text = processor.apply_chat_template(
|
| 230 |
+
messages, tokenize=False, add_generation_prompt=True
|
| 231 |
+
)
|
| 232 |
+
image_inputs, video_inputs = process_vision_info(messages)
|
| 233 |
+
inputs = processor(
|
| 234 |
+
text=[text],
|
| 235 |
+
images=image_inputs,
|
| 236 |
+
videos=video_inputs,
|
| 237 |
+
padding=True,
|
| 238 |
+
return_tensors="pt",
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
# 生成回答
|
| 242 |
+
with torch.no_grad():
|
| 243 |
+
generated_ids = model.generate(
|
| 244 |
+
**inputs,
|
| 245 |
+
max_new_tokens=512,
|
| 246 |
+
do_sample=False,
|
| 247 |
+
temperature=0.7,
|
| 248 |
+
top_p=0.9,
|
| 249 |
+
pad_token_id=processor.tokenizer.eos_token_id
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
generated_ids_trimmed = [
|
| 253 |
+
out_ids[len(in_ids):] for in_ids, out_ids in zip(inputs.input_ids, generated_ids)
|
| 254 |
+
]
|
| 255 |
+
|
| 256 |
+
output_text = processor.batch_decode(
|
| 257 |
+
generated_ids_trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False
|
| 258 |
+
)[0]
|
| 259 |
+
|
| 260 |
+
return {
|
| 261 |
+
"success": True,
|
| 262 |
+
"question": question,
|
| 263 |
+
"answer": output_text,
|
| 264 |
+
"image_info": {
|
| 265 |
+
"filename": image.filename,
|
| 266 |
+
"size": f"{pil_image.size[0]}x{pil_image.size[1]}",
|
| 267 |
+
"mode": pil_image.mode
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
except Exception as e:
|
| 272 |
+
logger.error(f"图片分析失败: {str(e)}")
|
| 273 |
+
return JSONResponse(
|
| 274 |
+
status_code=500,
|
| 275 |
+
content={"error": f"图片分析失败: {str(e)}"}
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
@app.post("/analyze_image_base64")
|
| 279 |
+
async def analyze_image_base64(
|
| 280 |
+
image_base64: str = Form(...),
|
| 281 |
+
question: str = Form("请��述这张图片的内容")
|
| 282 |
+
):
|
| 283 |
+
"""
|
| 284 |
+
分析 base64 编码的图片并回答问题
|
| 285 |
+
|
| 286 |
+
Args:
|
| 287 |
+
image_base64: base64 编码的图片数据
|
| 288 |
+
question: 关于图片的问题
|
| 289 |
+
|
| 290 |
+
Returns:
|
| 291 |
+
JSON 响应包含分析结果
|
| 292 |
+
"""
|
| 293 |
+
if model is None or processor is None:
|
| 294 |
+
return JSONResponse(
|
| 295 |
+
status_code=503,
|
| 296 |
+
content={"error": "模型未加载,请稍后重试"}
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
try:
|
| 300 |
+
# 解码 base64 图片
|
| 301 |
+
if image_base64.startswith('data:image'):
|
| 302 |
+
# 移除 data:image/xxx;base64, 前缀
|
| 303 |
+
image_base64 = image_base64.split(',')[1]
|
| 304 |
+
|
| 305 |
+
image_bytes = base64.b64decode(image_base64)
|
| 306 |
+
pil_image = Image.open(io.BytesIO(image_bytes))
|
| 307 |
+
|
| 308 |
+
# 确保图片是 RGB 格式
|
| 309 |
+
if pil_image.mode != 'RGB':
|
| 310 |
+
pil_image = pil_image.convert('RGB')
|
| 311 |
+
|
| 312 |
+
# 准备消息格式
|
| 313 |
+
messages = [
|
| 314 |
+
{
|
| 315 |
+
"role": "user",
|
| 316 |
+
"content": [
|
| 317 |
+
{
|
| 318 |
+
"type": "image",
|
| 319 |
+
"image": pil_image,
|
| 320 |
+
},
|
| 321 |
+
{"type": "text", "text": question},
|
| 322 |
+
],
|
| 323 |
+
}
|
| 324 |
+
]
|
| 325 |
+
|
| 326 |
+
# 处理输入
|
| 327 |
+
text = processor.apply_chat_template(
|
| 328 |
+
messages, tokenize=False, add_generation_prompt=True
|
| 329 |
+
)
|
| 330 |
+
image_inputs, video_inputs = process_vision_info(messages)
|
| 331 |
+
inputs = processor(
|
| 332 |
+
text=[text],
|
| 333 |
+
images=image_inputs,
|
| 334 |
+
videos=video_inputs,
|
| 335 |
+
padding=True,
|
| 336 |
+
return_tensors="pt",
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
# 生成回答
|
| 340 |
+
with torch.no_grad():
|
| 341 |
+
generated_ids = model.generate(
|
| 342 |
+
**inputs,
|
| 343 |
+
max_new_tokens=512,
|
| 344 |
+
do_sample=False,
|
| 345 |
+
temperature=0.7,
|
| 346 |
+
top_p=0.9,
|
| 347 |
+
pad_token_id=processor.tokenizer.eos_token_id
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
generated_ids_trimmed = [
|
| 351 |
+
out_ids[len(in_ids):] for in_ids, out_ids in zip(inputs.input_ids, generated_ids)
|
| 352 |
+
]
|
| 353 |
+
|
| 354 |
+
output_text = processor.batch_decode(
|
| 355 |
+
generated_ids_trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False
|
| 356 |
+
)[0]
|
| 357 |
+
|
| 358 |
+
return {
|
| 359 |
+
"success": True,
|
| 360 |
+
"question": question,
|
| 361 |
+
"answer": output_text,
|
| 362 |
+
"image_info": {
|
| 363 |
+
"size": f"{pil_image.size[0]}x{pil_image.size[1]}",
|
| 364 |
+
"mode": pil_image.mode
|
| 365 |
+
}
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
except Exception as e:
|
| 369 |
+
logger.error(f"图片分析失败: {str(e)}")
|
| 370 |
+
return JSONResponse(
|
| 371 |
+
status_code=500,
|
| 372 |
+
content={"error": f"图片分析失败: {str(e)}"}
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
@app.post("/analyze", response_model=AnalyzeResponse)
|
| 376 |
+
async def analyze_simple(request: AnalyzeRequest):
|
| 377 |
+
"""
|
| 378 |
+
简化的图片分析接口 (JSON 格式)
|
| 379 |
+
|
| 380 |
+
接收 JSON 格式的请求,包含 base64 图片和提示词
|
| 381 |
+
返回标准化的分析结果
|
| 382 |
+
"""
|
| 383 |
+
if model is None or processor is None:
|
| 384 |
+
return AnalyzeResponse(
|
| 385 |
+
success=False,
|
| 386 |
+
prompt=request.prompt,
|
| 387 |
+
response="",
|
| 388 |
+
processing_time=0,
|
| 389 |
+
error="模型未加载,请稍后重试"
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
start_time = time.time()
|
| 393 |
+
|
| 394 |
+
try:
|
| 395 |
+
# 处理 base64 图片
|
| 396 |
+
image_data = request.image
|
| 397 |
+
if image_data.startswith('data:image'):
|
| 398 |
+
# 移除 data:image/xxx;base64, 前缀
|
| 399 |
+
image_data = image_data.split(',')[1]
|
| 400 |
+
|
| 401 |
+
image_bytes = base64.b64decode(image_data)
|
| 402 |
+
pil_image = Image.open(io.BytesIO(image_bytes))
|
| 403 |
+
|
| 404 |
+
# 确保图片是 RGB 格式
|
| 405 |
+
if pil_image.mode != 'RGB':
|
| 406 |
+
pil_image = pil_image.convert('RGB')
|
| 407 |
+
|
| 408 |
+
# 准备消息格式
|
| 409 |
+
messages = [
|
| 410 |
+
{
|
| 411 |
+
"role": "user",
|
| 412 |
+
"content": [
|
| 413 |
+
{
|
| 414 |
+
"type": "image",
|
| 415 |
+
"image": pil_image,
|
| 416 |
+
},
|
| 417 |
+
{"type": "text", "text": request.prompt},
|
| 418 |
+
],
|
| 419 |
+
}
|
| 420 |
+
]
|
| 421 |
+
|
| 422 |
+
# 处理输入
|
| 423 |
+
text = processor.apply_chat_template(
|
| 424 |
+
messages, tokenize=False, add_generation_prompt=True
|
| 425 |
+
)
|
| 426 |
+
image_inputs, video_inputs = process_vision_info(messages)
|
| 427 |
+
inputs = processor(
|
| 428 |
+
text=[text],
|
| 429 |
+
images=image_inputs,
|
| 430 |
+
videos=video_inputs,
|
| 431 |
+
padding=True,
|
| 432 |
+
return_tensors="pt",
|
| 433 |
+
)
|
| 434 |
+
|
| 435 |
+
# 生成回答
|
| 436 |
+
with torch.no_grad():
|
| 437 |
+
generated_ids = model.generate(
|
| 438 |
+
**inputs,
|
| 439 |
+
max_new_tokens=512,
|
| 440 |
+
do_sample=False,
|
| 441 |
+
temperature=0.7,
|
| 442 |
+
top_p=0.9,
|
| 443 |
+
pad_token_id=processor.tokenizer.eos_token_id
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
generated_ids_trimmed = [
|
| 447 |
+
out_ids[len(in_ids):] for in_ids, out_ids in zip(inputs.input_ids, generated_ids)
|
| 448 |
+
]
|
| 449 |
+
|
| 450 |
+
output_text = processor.batch_decode(
|
| 451 |
+
generated_ids_trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False
|
| 452 |
+
)[0]
|
| 453 |
+
|
| 454 |
+
processing_time = time.time() - start_time
|
| 455 |
+
|
| 456 |
+
return AnalyzeResponse(
|
| 457 |
+
success=True,
|
| 458 |
+
prompt=request.prompt,
|
| 459 |
+
response=output_text,
|
| 460 |
+
processing_time=processing_time,
|
| 461 |
+
image_info={
|
| 462 |
+
"size": f"{pil_image.size[0]}x{pil_image.size[1]}",
|
| 463 |
+
"mode": pil_image.mode,
|
| 464 |
+
"format": pil_image.format or "Unknown"
|
| 465 |
+
}
|
| 466 |
+
)
|
| 467 |
+
|
| 468 |
+
except Exception as e:
|
| 469 |
+
processing_time = time.time() - start_time
|
| 470 |
+
logger.error(f"图片分析失败: {str(e)}")
|
| 471 |
+
|
| 472 |
+
return AnalyzeResponse(
|
| 473 |
+
success=False,
|
| 474 |
+
prompt=request.prompt,
|
| 475 |
+
response="",
|
| 476 |
+
processing_time=processing_time,
|
| 477 |
+
error=f"图片分析失败: {str(e)}"
|
| 478 |
+
)
|
| 479 |
+
|
| 480 |
+
@app.get("/memory_status")
|
| 481 |
+
def get_memory_status():
|
| 482 |
+
"""获取当前内存使用状态"""
|
| 483 |
+
try:
|
| 484 |
+
# 系统内存信息
|
| 485 |
+
memory = psutil.virtual_memory()
|
| 486 |
+
|
| 487 |
+
# PyTorch 内存信息(如果使用 CUDA)
|
| 488 |
+
torch_memory = {}
|
| 489 |
+
if torch.cuda.is_available():
|
| 490 |
+
torch_memory = {
|
| 491 |
+
"cuda_allocated": torch.cuda.memory_allocated() / 1024**3, # GB
|
| 492 |
+
"cuda_reserved": torch.cuda.memory_reserved() / 1024**3, # GB
|
| 493 |
+
"cuda_max_allocated": torch.cuda.max_memory_allocated() / 1024**3, # GB
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
return {
|
| 497 |
+
"system_memory": {
|
| 498 |
+
"total_gb": memory.total / 1024**3,
|
| 499 |
+
"available_gb": memory.available / 1024**3,
|
| 500 |
+
"used_gb": memory.used / 1024**3,
|
| 501 |
+
"percent": memory.percent
|
| 502 |
+
},
|
| 503 |
+
"torch_memory": torch_memory,
|
| 504 |
+
"model_loaded": model is not None,
|
| 505 |
+
"recommendations": {
|
| 506 |
+
"memory_usage_ok": memory.percent < 85,
|
| 507 |
+
"available_for_inference": memory.available / 1024**3 > 2.0
|
| 508 |
+
}
|
| 509 |
+
}
|
| 510 |
+
except Exception as e:
|
| 511 |
+
return JSONResponse(
|
| 512 |
+
status_code=500,
|
| 513 |
+
content={"error": f"获取内存状态失败: {str(e)}"}
|
| 514 |
+
)
|
| 515 |
+
|
| 516 |
+
@app.post("/clear_cache")
|
| 517 |
+
def clear_cache():
|
| 518 |
+
"""清理内存缓存"""
|
| 519 |
+
try:
|
| 520 |
+
# Python 垃圾回收
|
| 521 |
+
gc.collect()
|
| 522 |
+
|
| 523 |
+
# PyTorch 缓存清理
|
| 524 |
+
if torch.cuda.is_available():
|
| 525 |
+
torch.cuda.empty_cache()
|
| 526 |
+
|
| 527 |
+
return {"success": True, "message": "缓存清理完成"}
|
| 528 |
+
except Exception as e:
|
| 529 |
+
return JSONResponse(
|
| 530 |
+
status_code=500,
|
| 531 |
+
content={"error": f"缓存清理失败: {str(e)}"}
|
| 532 |
+
)
|
| 533 |
+
|
| 534 |
+
@app.get("/web")
|
| 535 |
+
def web_interface():
|
| 536 |
+
"""返回 Web 界面"""
|
| 537 |
+
return FileResponse("static/index.html")
|
example_usage.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
PicExam API 使用示例
|
| 4 |
+
演示如何通过不同方式调用 Qwen-VL 图像分析 API
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import requests
|
| 8 |
+
import base64
|
| 9 |
+
import json
|
| 10 |
+
from PIL import Image
|
| 11 |
+
import io
|
| 12 |
+
|
| 13 |
+
# API 基础 URL
|
| 14 |
+
BASE_URL = "http://localhost:7860"
|
| 15 |
+
|
| 16 |
+
def create_sample_image():
|
| 17 |
+
"""创建一个示例图片用于测试"""
|
| 18 |
+
# 创建一个简单的测试图片
|
| 19 |
+
img = Image.new('RGB', (300, 200), color='lightblue')
|
| 20 |
+
|
| 21 |
+
# 添加一些图形
|
| 22 |
+
from PIL import ImageDraw, ImageFont
|
| 23 |
+
draw = ImageDraw.Draw(img)
|
| 24 |
+
|
| 25 |
+
# 绘制矩形
|
| 26 |
+
draw.rectangle([50, 50, 150, 100], fill='red', outline='black', width=2)
|
| 27 |
+
|
| 28 |
+
# 绘制圆形
|
| 29 |
+
draw.ellipse([180, 60, 250, 130], fill='yellow', outline='black', width=2)
|
| 30 |
+
|
| 31 |
+
# 添加文字
|
| 32 |
+
try:
|
| 33 |
+
# 尝试使用默认字体
|
| 34 |
+
draw.text((100, 150), "Sample Image", fill='black')
|
| 35 |
+
except:
|
| 36 |
+
# 如果没有字体,跳过文字
|
| 37 |
+
pass
|
| 38 |
+
|
| 39 |
+
return img
|
| 40 |
+
|
| 41 |
+
def image_to_base64(image):
|
| 42 |
+
"""将 PIL 图片转换为 base64 字符串"""
|
| 43 |
+
buffer = io.BytesIO()
|
| 44 |
+
image.save(buffer, format='PNG')
|
| 45 |
+
img_str = base64.b64encode(buffer.getvalue()).decode()
|
| 46 |
+
return f"data:image/png;base64,{img_str}"
|
| 47 |
+
|
| 48 |
+
def test_api_info():
|
| 49 |
+
"""测试 API 信息获取"""
|
| 50 |
+
print("🔍 获取 API 信息...")
|
| 51 |
+
try:
|
| 52 |
+
response = requests.get(f"{BASE_URL}/")
|
| 53 |
+
if response.status_code == 200:
|
| 54 |
+
data = response.json()
|
| 55 |
+
print(f"✅ 服务: {data['service']}")
|
| 56 |
+
print(f"✅ 版本: {data['version']}")
|
| 57 |
+
print(f"✅ 模型: {data['model']}")
|
| 58 |
+
print(f"✅ 模型状态: {'已加载' if data['status']['model_loaded'] else '未加载'}")
|
| 59 |
+
print(f"✅ 可用端点数量: {len(data['endpoints'])}")
|
| 60 |
+
return True
|
| 61 |
+
else:
|
| 62 |
+
print(f"❌ 获取 API 信息失败: {response.status_code}")
|
| 63 |
+
return False
|
| 64 |
+
except Exception as e:
|
| 65 |
+
print(f"❌ 连接失败: {e}")
|
| 66 |
+
return False
|
| 67 |
+
|
| 68 |
+
def test_health_check():
|
| 69 |
+
"""测试健康检查"""
|
| 70 |
+
print("\n🔍 健康检查...")
|
| 71 |
+
try:
|
| 72 |
+
response = requests.get(f"{BASE_URL}/health")
|
| 73 |
+
if response.status_code == 200:
|
| 74 |
+
data = response.json()
|
| 75 |
+
print(f"✅ 状态: {data['status']}")
|
| 76 |
+
print(f"✅ 模型: {'已加载' if data['model_loaded'] else '未加载'}")
|
| 77 |
+
return True
|
| 78 |
+
else:
|
| 79 |
+
print(f"❌ 健康检查失败: {response.status_code}")
|
| 80 |
+
return False
|
| 81 |
+
except Exception as e:
|
| 82 |
+
print(f"❌ 健康检查异常: {e}")
|
| 83 |
+
return False
|
| 84 |
+
|
| 85 |
+
def test_file_upload():
|
| 86 |
+
"""测试文件上传方式"""
|
| 87 |
+
print("\n🔍 测试文件上传分析...")
|
| 88 |
+
|
| 89 |
+
# 创建测试图片
|
| 90 |
+
test_img = create_sample_image()
|
| 91 |
+
test_img.save("temp_test.png")
|
| 92 |
+
|
| 93 |
+
try:
|
| 94 |
+
with open("temp_test.png", "rb") as f:
|
| 95 |
+
files = {"image": ("test.png", f, "image/png")}
|
| 96 |
+
data = {"question": "请描述这张图片中的颜色和形状"}
|
| 97 |
+
|
| 98 |
+
response = requests.post(f"{BASE_URL}/analyze_image", files=files, data=data)
|
| 99 |
+
|
| 100 |
+
if response.status_code == 200:
|
| 101 |
+
result = response.json()
|
| 102 |
+
print(f"✅ 分析成功!")
|
| 103 |
+
print(f" 问题: {result['question']}")
|
| 104 |
+
print(f" 回答: {result['answer']}")
|
| 105 |
+
print(f" 图片信息: {result['image_info']}")
|
| 106 |
+
return True
|
| 107 |
+
else:
|
| 108 |
+
print(f"❌ 文件上传分析失败: {response.status_code}")
|
| 109 |
+
print(f" 错误: {response.text}")
|
| 110 |
+
return False
|
| 111 |
+
|
| 112 |
+
except Exception as e:
|
| 113 |
+
print(f"❌ 文件上传测试异常: {e}")
|
| 114 |
+
return False
|
| 115 |
+
finally:
|
| 116 |
+
# 清理临时文件
|
| 117 |
+
try:
|
| 118 |
+
import os
|
| 119 |
+
os.remove("temp_test.png")
|
| 120 |
+
except:
|
| 121 |
+
pass
|
| 122 |
+
|
| 123 |
+
def test_base64_form():
|
| 124 |
+
"""测试 base64 表单方式"""
|
| 125 |
+
print("\n🔍 测试 base64 表单分析...")
|
| 126 |
+
|
| 127 |
+
try:
|
| 128 |
+
# 创建测试图片并转换为 base64
|
| 129 |
+
test_img = create_sample_image()
|
| 130 |
+
img_base64 = image_to_base64(test_img)
|
| 131 |
+
|
| 132 |
+
data = {
|
| 133 |
+
"image_base64": img_base64,
|
| 134 |
+
"question": "这张图片中有什么几何形状?"
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
response = requests.post(f"{BASE_URL}/analyze_image_base64", data=data)
|
| 138 |
+
|
| 139 |
+
if response.status_code == 200:
|
| 140 |
+
result = response.json()
|
| 141 |
+
print(f"✅ 分析成功!")
|
| 142 |
+
print(f" 问题: {result['question']}")
|
| 143 |
+
print(f" 回答: {result['answer']}")
|
| 144 |
+
print(f" 图片信息: {result['image_info']}")
|
| 145 |
+
return True
|
| 146 |
+
else:
|
| 147 |
+
print(f"❌ base64 表单分析失败: {response.status_code}")
|
| 148 |
+
print(f" 错误: {response.text}")
|
| 149 |
+
return False
|
| 150 |
+
|
| 151 |
+
except Exception as e:
|
| 152 |
+
print(f"❌ base64 表单测试异常: {e}")
|
| 153 |
+
return False
|
| 154 |
+
|
| 155 |
+
def test_json_api():
|
| 156 |
+
"""测试 JSON API 方式"""
|
| 157 |
+
print("\n🔍 测试 JSON API 分析...")
|
| 158 |
+
|
| 159 |
+
try:
|
| 160 |
+
# 创建测试图片并转换为 base64
|
| 161 |
+
test_img = create_sample_image()
|
| 162 |
+
img_base64 = image_to_base64(test_img)
|
| 163 |
+
|
| 164 |
+
request_data = {
|
| 165 |
+
"image": img_base64,
|
| 166 |
+
"prompt": "请详细分析这张图片的构成元素,包括颜色、形状和布局"
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
response = requests.post(
|
| 170 |
+
f"{BASE_URL}/analyze",
|
| 171 |
+
headers={"Content-Type": "application/json"},
|
| 172 |
+
json=request_data
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
if response.status_code == 200:
|
| 176 |
+
result = response.json()
|
| 177 |
+
print(f"✅ 分析成功!")
|
| 178 |
+
print(f" 提示词: {result['prompt']}")
|
| 179 |
+
print(f" 响应: {result['response']}")
|
| 180 |
+
print(f" 处理时间: {result['processing_time']:.2f}秒")
|
| 181 |
+
print(f" 图片信息: {result['image_info']}")
|
| 182 |
+
return True
|
| 183 |
+
else:
|
| 184 |
+
print(f"❌ JSON API 分析失败: {response.status_code}")
|
| 185 |
+
print(f" 错误: {response.text}")
|
| 186 |
+
return False
|
| 187 |
+
|
| 188 |
+
except Exception as e:
|
| 189 |
+
print(f"❌ JSON API 测试异常: {e}")
|
| 190 |
+
return False
|
| 191 |
+
|
| 192 |
+
def test_memory_status():
|
| 193 |
+
"""测试内存状态"""
|
| 194 |
+
print("\n🔍 检查内存状态...")
|
| 195 |
+
try:
|
| 196 |
+
response = requests.get(f"{BASE_URL}/memory_status")
|
| 197 |
+
if response.status_code == 200:
|
| 198 |
+
data = response.json()
|
| 199 |
+
memory = data['system_memory']
|
| 200 |
+
print(f"✅ 系统内存: {memory['used_gb']:.2f}GB / {memory['total_gb']:.2f}GB ({memory['percent']:.1f}%)")
|
| 201 |
+
print(f"✅ 可用内存: {memory['available_gb']:.2f}GB")
|
| 202 |
+
print(f"✅ 内存状态: {'正常' if data['recommendations']['memory_usage_ok'] else '紧张'}")
|
| 203 |
+
return True
|
| 204 |
+
else:
|
| 205 |
+
print(f"❌ 内存状态检查失败: {response.status_code}")
|
| 206 |
+
return False
|
| 207 |
+
except Exception as e:
|
| 208 |
+
print(f"❌ 内存状态检查异常: {e}")
|
| 209 |
+
return False
|
| 210 |
+
|
| 211 |
+
def main():
|
| 212 |
+
"""主测试函数"""
|
| 213 |
+
print("🚀 PicExam API 使用示例")
|
| 214 |
+
print("=" * 60)
|
| 215 |
+
|
| 216 |
+
tests = [
|
| 217 |
+
("API 信息获取", test_api_info),
|
| 218 |
+
("健康检查", test_health_check),
|
| 219 |
+
("内存状态", test_memory_status),
|
| 220 |
+
("文件上传分析", test_file_upload),
|
| 221 |
+
("Base64 表单分析", test_base64_form),
|
| 222 |
+
("JSON API 分析", test_json_api),
|
| 223 |
+
]
|
| 224 |
+
|
| 225 |
+
results = []
|
| 226 |
+
for test_name, test_func in tests:
|
| 227 |
+
try:
|
| 228 |
+
result = test_func()
|
| 229 |
+
results.append((test_name, result))
|
| 230 |
+
if result:
|
| 231 |
+
print(f"✅ {test_name} - 成功")
|
| 232 |
+
else:
|
| 233 |
+
print(f"❌ {test_name} - 失败")
|
| 234 |
+
except Exception as e:
|
| 235 |
+
print(f"❌ {test_name} - 异常: {e}")
|
| 236 |
+
results.append((test_name, False))
|
| 237 |
+
|
| 238 |
+
print("-" * 40)
|
| 239 |
+
|
| 240 |
+
# 总结
|
| 241 |
+
print("\n📊 测试结果总结:")
|
| 242 |
+
passed = sum(1 for _, result in results if result)
|
| 243 |
+
total = len(results)
|
| 244 |
+
|
| 245 |
+
for test_name, result in results:
|
| 246 |
+
status = "✅ 通过" if result else "❌ 失败"
|
| 247 |
+
print(f" {test_name}: {status}")
|
| 248 |
+
|
| 249 |
+
print(f"\n总计: {passed}/{total} 测试通过")
|
| 250 |
+
|
| 251 |
+
if passed == total:
|
| 252 |
+
print("🎉 所有测试都通过了!API 运行正常。")
|
| 253 |
+
print("\n💡 使用提示:")
|
| 254 |
+
print(f" - Web 界面: {BASE_URL}/web")
|
| 255 |
+
print(f" - API 文档: {BASE_URL}/docs")
|
| 256 |
+
print(f" - API 信息: {BASE_URL}/")
|
| 257 |
+
else:
|
| 258 |
+
print("⚠️ 部分测试失败,请检查服务状态。")
|
| 259 |
+
|
| 260 |
+
if __name__ == "__main__":
|
| 261 |
+
main()
|
quick_test.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
快速测试脚本 - 验证 PicExam API 的核心功能
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import requests
|
| 7 |
+
import time
|
| 8 |
+
import json
|
| 9 |
+
|
| 10 |
+
def wait_for_service(max_wait=60):
|
| 11 |
+
"""等待服务启动"""
|
| 12 |
+
print("⏳ 等待服务启动...")
|
| 13 |
+
start_time = time.time()
|
| 14 |
+
|
| 15 |
+
while time.time() - start_time < max_wait:
|
| 16 |
+
try:
|
| 17 |
+
response = requests.get("http://localhost:7860/health", timeout=5)
|
| 18 |
+
if response.status_code == 200:
|
| 19 |
+
print("✅ 服务已启动")
|
| 20 |
+
return True
|
| 21 |
+
except:
|
| 22 |
+
pass
|
| 23 |
+
|
| 24 |
+
print(".", end="", flush=True)
|
| 25 |
+
time.sleep(2)
|
| 26 |
+
|
| 27 |
+
print("\n❌ 服务启动超时")
|
| 28 |
+
return False
|
| 29 |
+
|
| 30 |
+
def test_endpoints():
|
| 31 |
+
"""测试主要端点"""
|
| 32 |
+
print("\n🔍 测试 API 端点...")
|
| 33 |
+
|
| 34 |
+
endpoints = [
|
| 35 |
+
("GET /", "API 信息"),
|
| 36 |
+
("GET /health", "健康检查"),
|
| 37 |
+
("GET /memory_status", "内存状态"),
|
| 38 |
+
("GET /web", "Web 界面"),
|
| 39 |
+
("GET /docs", "API 文档")
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
results = []
|
| 43 |
+
for endpoint, description in endpoints:
|
| 44 |
+
try:
|
| 45 |
+
method, path = endpoint.split(" ", 1)
|
| 46 |
+
url = f"http://localhost:7860{path}"
|
| 47 |
+
|
| 48 |
+
if method == "GET":
|
| 49 |
+
response = requests.get(url, timeout=10)
|
| 50 |
+
|
| 51 |
+
if response.status_code == 200:
|
| 52 |
+
print(f"✅ {endpoint} - {description}")
|
| 53 |
+
results.append(True)
|
| 54 |
+
else:
|
| 55 |
+
print(f"❌ {endpoint} - {description} (状态码: {response.status_code})")
|
| 56 |
+
results.append(False)
|
| 57 |
+
|
| 58 |
+
except Exception as e:
|
| 59 |
+
print(f"❌ {endpoint} - {description} (错误: {e})")
|
| 60 |
+
results.append(False)
|
| 61 |
+
|
| 62 |
+
return results
|
| 63 |
+
|
| 64 |
+
def show_api_info():
|
| 65 |
+
"""显示 API 信息"""
|
| 66 |
+
print("\n📋 API 信息:")
|
| 67 |
+
try:
|
| 68 |
+
response = requests.get("http://localhost:7860/")
|
| 69 |
+
if response.status_code == 200:
|
| 70 |
+
data = response.json()
|
| 71 |
+
print(f" 服务: {data['service']}")
|
| 72 |
+
print(f" 版本: {data['version']}")
|
| 73 |
+
print(f" 模型: {data['model']}")
|
| 74 |
+
print(f" 状态: {'✅ 模型已加载' if data['status']['model_loaded'] else '⏳ 模型加载中'}")
|
| 75 |
+
print(f" 推理模式: {data['status']['inference_mode']}")
|
| 76 |
+
|
| 77 |
+
print(f"\n📚 可用端点 ({len(data['endpoints'])} 个):")
|
| 78 |
+
for endpoint, info in data['endpoints'].items():
|
| 79 |
+
print(f" {endpoint}: {info['description']}")
|
| 80 |
+
|
| 81 |
+
return True
|
| 82 |
+
else:
|
| 83 |
+
print("❌ 无法获取 API 信息")
|
| 84 |
+
return False
|
| 85 |
+
except Exception as e:
|
| 86 |
+
print(f"❌ 获取 API 信息失败: {e}")
|
| 87 |
+
return False
|
| 88 |
+
|
| 89 |
+
def show_usage_examples():
|
| 90 |
+
"""显示使用示例"""
|
| 91 |
+
print("\n💡 使用示例:")
|
| 92 |
+
print("=" * 50)
|
| 93 |
+
|
| 94 |
+
examples = [
|
| 95 |
+
{
|
| 96 |
+
"title": "1. 浏览器访问",
|
| 97 |
+
"commands": [
|
| 98 |
+
"Web 界面: http://localhost:7860/web",
|
| 99 |
+
"API 文档: http://localhost:7860/docs",
|
| 100 |
+
"API 信息: http://localhost:7860/"
|
| 101 |
+
]
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
"title": "2. 文件上传分析",
|
| 105 |
+
"commands": [
|
| 106 |
+
"curl -X POST 'http://localhost:7860/analyze_image' \\",
|
| 107 |
+
" -F 'image=@your_image.jpg' \\",
|
| 108 |
+
" -F 'question=请描述这张图片'"
|
| 109 |
+
]
|
| 110 |
+
},
|
| 111 |
+
{
|
| 112 |
+
"title": "3. Base64 图片分析",
|
| 113 |
+
"commands": [
|
| 114 |
+
"curl -X POST 'http://localhost:7860/analyze_image_base64' \\",
|
| 115 |
+
" -F 'image_base64=data:image/jpeg;base64,/9j/4AAQ...' \\",
|
| 116 |
+
" -F 'question=这张图片中有什么?'"
|
| 117 |
+
]
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"title": "4. JSON API 调用",
|
| 121 |
+
"commands": [
|
| 122 |
+
"curl -X POST 'http://localhost:7860/analyze' \\",
|
| 123 |
+
" -H 'Content-Type: application/json' \\",
|
| 124 |
+
" -d '{\"image\":\"data:image/jpeg;base64,...\",\"prompt\":\"描述图片\"}'",
|
| 125 |
+
]
|
| 126 |
+
},
|
| 127 |
+
{
|
| 128 |
+
"title": "5. Python 调用示例",
|
| 129 |
+
"commands": [
|
| 130 |
+
"python example_usage.py # 运行完整示例",
|
| 131 |
+
"python test_api.py # 运行详细测试"
|
| 132 |
+
]
|
| 133 |
+
}
|
| 134 |
+
]
|
| 135 |
+
|
| 136 |
+
for example in examples:
|
| 137 |
+
print(f"\n{example['title']}:")
|
| 138 |
+
for cmd in example['commands']:
|
| 139 |
+
print(f" {cmd}")
|
| 140 |
+
|
| 141 |
+
def main():
|
| 142 |
+
"""主函数"""
|
| 143 |
+
print("🚀 PicExam API 快速测试")
|
| 144 |
+
print("=" * 50)
|
| 145 |
+
|
| 146 |
+
# 等待服务启动
|
| 147 |
+
if not wait_for_service():
|
| 148 |
+
print("❌ 服务未启动,请先运行:")
|
| 149 |
+
print(" python start_local.py")
|
| 150 |
+
print(" 或")
|
| 151 |
+
print(" uvicorn app:app --host 0.0.0.0 --port 7860")
|
| 152 |
+
return
|
| 153 |
+
|
| 154 |
+
# 显示 API 信息
|
| 155 |
+
show_api_info()
|
| 156 |
+
|
| 157 |
+
# 测试端点
|
| 158 |
+
results = test_endpoints()
|
| 159 |
+
|
| 160 |
+
# 显示测试结果
|
| 161 |
+
passed = sum(results)
|
| 162 |
+
total = len(results)
|
| 163 |
+
print(f"\n📊 端点测试结果: {passed}/{total} 通过")
|
| 164 |
+
|
| 165 |
+
if passed == total:
|
| 166 |
+
print("🎉 所有基础端点都正常工作!")
|
| 167 |
+
else:
|
| 168 |
+
print("⚠️ 部分端点可能有问题")
|
| 169 |
+
|
| 170 |
+
# 显示使用示例
|
| 171 |
+
show_usage_examples()
|
| 172 |
+
|
| 173 |
+
print("\n" + "=" * 50)
|
| 174 |
+
print("✨ 快速测试完成!")
|
| 175 |
+
print("💡 提示: 运行 'python example_usage.py' 进行完整的功能测试")
|
| 176 |
+
|
| 177 |
+
if __name__ == "__main__":
|
| 178 |
+
main()
|
requirements.txt
CHANGED
|
@@ -1,2 +1,10 @@
|
|
| 1 |
fastapi
|
| 2 |
uvicorn[standard]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
fastapi
|
| 2 |
uvicorn[standard]
|
| 3 |
+
torch>=2.0.0
|
| 4 |
+
transformers>=4.37.0
|
| 5 |
+
accelerate
|
| 6 |
+
qwen-vl-utils
|
| 7 |
+
Pillow
|
| 8 |
+
requests
|
| 9 |
+
numpy
|
| 10 |
+
psutil
|
start_local.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
本地启动 Qwen-VL PicExam API 的脚本
|
| 4 |
+
适用于 16GB 内存 + CPU 推理环境
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import subprocess
|
| 8 |
+
import sys
|
| 9 |
+
import os
|
| 10 |
+
import time
|
| 11 |
+
import psutil
|
| 12 |
+
|
| 13 |
+
def check_memory():
|
| 14 |
+
"""检查系统内存是否足够"""
|
| 15 |
+
memory = psutil.virtual_memory()
|
| 16 |
+
total_gb = memory.total / 1024**3
|
| 17 |
+
available_gb = memory.available / 1024**3
|
| 18 |
+
|
| 19 |
+
print(f"💾 系统内存状态:")
|
| 20 |
+
print(f" 总内存: {total_gb:.1f}GB")
|
| 21 |
+
print(f" 可用内存: {available_gb:.1f}GB")
|
| 22 |
+
print(f" 使用率: {memory.percent:.1f}%")
|
| 23 |
+
|
| 24 |
+
if total_gb < 15:
|
| 25 |
+
print("⚠️ 警告: 系统内存少于 16GB,可能影响模型运行")
|
| 26 |
+
return False
|
| 27 |
+
|
| 28 |
+
if available_gb < 8:
|
| 29 |
+
print("⚠️ 警告: 可用内存少于 8GB,建议关闭其他程序")
|
| 30 |
+
return False
|
| 31 |
+
|
| 32 |
+
print("✅ 内存检查通过")
|
| 33 |
+
return True
|
| 34 |
+
|
| 35 |
+
def install_dependencies():
|
| 36 |
+
"""安装依赖包"""
|
| 37 |
+
print("📦 安装依赖包...")
|
| 38 |
+
try:
|
| 39 |
+
subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"])
|
| 40 |
+
print("✅ 依赖包安装完成")
|
| 41 |
+
return True
|
| 42 |
+
except subprocess.CalledProcessError as e:
|
| 43 |
+
print(f"❌ 依赖包安装失败: {e}")
|
| 44 |
+
return False
|
| 45 |
+
|
| 46 |
+
def start_server():
|
| 47 |
+
"""启动服务器"""
|
| 48 |
+
print("🚀 启动 Qwen-VL PicExam API 服务器...")
|
| 49 |
+
print("📝 注意: 首次启动会下载模型,可能需要较长时间")
|
| 50 |
+
print("🔗 服务器启动后可访问: http://localhost:7860")
|
| 51 |
+
print("📚 API 文档: http://localhost:7860/docs")
|
| 52 |
+
print("-" * 50)
|
| 53 |
+
|
| 54 |
+
try:
|
| 55 |
+
# 设置环境变量
|
| 56 |
+
env = os.environ.copy()
|
| 57 |
+
env["PYTORCH_CUDA_ALLOC_CONF"] = "max_split_size_mb:512"
|
| 58 |
+
env["TOKENIZERS_PARALLELISM"] = "false"
|
| 59 |
+
env["OMP_NUM_THREADS"] = "4"
|
| 60 |
+
env["MKL_NUM_THREADS"] = "4"
|
| 61 |
+
|
| 62 |
+
# 启动服务器
|
| 63 |
+
subprocess.run([
|
| 64 |
+
sys.executable, "-m", "uvicorn",
|
| 65 |
+
"app:app",
|
| 66 |
+
"--host", "0.0.0.0",
|
| 67 |
+
"--port", "7860",
|
| 68 |
+
"--reload",
|
| 69 |
+
"--timeout-keep-alive", "300"
|
| 70 |
+
], env=env)
|
| 71 |
+
|
| 72 |
+
except KeyboardInterrupt:
|
| 73 |
+
print("\n🛑 服务器已停止")
|
| 74 |
+
except Exception as e:
|
| 75 |
+
print(f"❌ 服务器启动失败: {e}")
|
| 76 |
+
|
| 77 |
+
def main():
|
| 78 |
+
"""主函数"""
|
| 79 |
+
print("🤖 Qwen-VL PicExam API 本地启动器")
|
| 80 |
+
print("=" * 50)
|
| 81 |
+
print("📋 配置信息:")
|
| 82 |
+
print(" - 模型: Qwen2-VL-2B-Instruct")
|
| 83 |
+
print(" - 推理: CPU 模式")
|
| 84 |
+
print(" - 内存优化: 启用")
|
| 85 |
+
print(" - 端口: 7860")
|
| 86 |
+
print("=" * 50)
|
| 87 |
+
|
| 88 |
+
# 检查内存
|
| 89 |
+
if not check_memory():
|
| 90 |
+
response = input("是否继续启动? (y/N): ")
|
| 91 |
+
if response.lower() != 'y':
|
| 92 |
+
print("启动已取消")
|
| 93 |
+
return
|
| 94 |
+
|
| 95 |
+
# 安装依赖
|
| 96 |
+
if not install_dependencies():
|
| 97 |
+
print("❌ 无法安装依赖,启动失败")
|
| 98 |
+
return
|
| 99 |
+
|
| 100 |
+
# 启动服务器
|
| 101 |
+
start_server()
|
| 102 |
+
|
| 103 |
+
if __name__ == "__main__":
|
| 104 |
+
main()
|
static/index.html
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>PicExam - Qwen-VL 图像理解</title>
|
| 7 |
+
<style>
|
| 8 |
+
* {
|
| 9 |
+
margin: 0;
|
| 10 |
+
padding: 0;
|
| 11 |
+
box-sizing: border-box;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
body {
|
| 15 |
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
| 16 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 17 |
+
min-height: 100vh;
|
| 18 |
+
padding: 20px;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
.container {
|
| 22 |
+
max-width: 800px;
|
| 23 |
+
margin: 0 auto;
|
| 24 |
+
background: white;
|
| 25 |
+
border-radius: 15px;
|
| 26 |
+
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
| 27 |
+
overflow: hidden;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
.header {
|
| 31 |
+
background: linear-gradient(135deg, #ff6b6b, #ee5a24);
|
| 32 |
+
color: white;
|
| 33 |
+
padding: 30px;
|
| 34 |
+
text-align: center;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
.header h1 {
|
| 38 |
+
font-size: 2.5em;
|
| 39 |
+
margin-bottom: 10px;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
.header p {
|
| 43 |
+
font-size: 1.1em;
|
| 44 |
+
opacity: 0.9;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.content {
|
| 48 |
+
padding: 30px;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.upload-area {
|
| 52 |
+
border: 3px dashed #ddd;
|
| 53 |
+
border-radius: 10px;
|
| 54 |
+
padding: 40px;
|
| 55 |
+
text-align: center;
|
| 56 |
+
margin-bottom: 20px;
|
| 57 |
+
transition: all 0.3s ease;
|
| 58 |
+
cursor: pointer;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.upload-area:hover {
|
| 62 |
+
border-color: #667eea;
|
| 63 |
+
background-color: #f8f9ff;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
.upload-area.dragover {
|
| 67 |
+
border-color: #667eea;
|
| 68 |
+
background-color: #f0f2ff;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.upload-icon {
|
| 72 |
+
font-size: 3em;
|
| 73 |
+
color: #ddd;
|
| 74 |
+
margin-bottom: 15px;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.form-group {
|
| 78 |
+
margin-bottom: 20px;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
label {
|
| 82 |
+
display: block;
|
| 83 |
+
margin-bottom: 8px;
|
| 84 |
+
font-weight: 600;
|
| 85 |
+
color: #333;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
input[type="file"], textarea {
|
| 89 |
+
width: 100%;
|
| 90 |
+
padding: 12px;
|
| 91 |
+
border: 2px solid #ddd;
|
| 92 |
+
border-radius: 8px;
|
| 93 |
+
font-size: 16px;
|
| 94 |
+
transition: border-color 0.3s ease;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
input[type="file"]:focus, textarea:focus {
|
| 98 |
+
outline: none;
|
| 99 |
+
border-color: #667eea;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
textarea {
|
| 103 |
+
resize: vertical;
|
| 104 |
+
min-height: 80px;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.btn {
|
| 108 |
+
background: linear-gradient(135deg, #667eea, #764ba2);
|
| 109 |
+
color: white;
|
| 110 |
+
border: none;
|
| 111 |
+
padding: 15px 30px;
|
| 112 |
+
border-radius: 8px;
|
| 113 |
+
font-size: 16px;
|
| 114 |
+
font-weight: 600;
|
| 115 |
+
cursor: pointer;
|
| 116 |
+
transition: all 0.3s ease;
|
| 117 |
+
width: 100%;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.btn:hover {
|
| 121 |
+
transform: translateY(-2px);
|
| 122 |
+
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.btn:disabled {
|
| 126 |
+
background: #ccc;
|
| 127 |
+
cursor: not-allowed;
|
| 128 |
+
transform: none;
|
| 129 |
+
box-shadow: none;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.result {
|
| 133 |
+
margin-top: 30px;
|
| 134 |
+
padding: 20px;
|
| 135 |
+
background: #f8f9fa;
|
| 136 |
+
border-radius: 10px;
|
| 137 |
+
border-left: 5px solid #667eea;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.result h3 {
|
| 141 |
+
color: #333;
|
| 142 |
+
margin-bottom: 15px;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.result-content {
|
| 146 |
+
background: white;
|
| 147 |
+
padding: 15px;
|
| 148 |
+
border-radius: 8px;
|
| 149 |
+
border: 1px solid #e9ecef;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.loading {
|
| 153 |
+
display: none;
|
| 154 |
+
text-align: center;
|
| 155 |
+
padding: 20px;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
.spinner {
|
| 159 |
+
border: 4px solid #f3f3f3;
|
| 160 |
+
border-top: 4px solid #667eea;
|
| 161 |
+
border-radius: 50%;
|
| 162 |
+
width: 40px;
|
| 163 |
+
height: 40px;
|
| 164 |
+
animation: spin 1s linear infinite;
|
| 165 |
+
margin: 0 auto 15px;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
@keyframes spin {
|
| 169 |
+
0% { transform: rotate(0deg); }
|
| 170 |
+
100% { transform: rotate(360deg); }
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.error {
|
| 174 |
+
background: #ffe6e6;
|
| 175 |
+
border-left-color: #ff4757;
|
| 176 |
+
color: #c44569;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.preview-image {
|
| 180 |
+
max-width: 100%;
|
| 181 |
+
max-height: 300px;
|
| 182 |
+
border-radius: 8px;
|
| 183 |
+
margin: 15px 0;
|
| 184 |
+
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.status-bar {
|
| 188 |
+
background: #f8f9fa;
|
| 189 |
+
padding: 15px;
|
| 190 |
+
border-radius: 8px;
|
| 191 |
+
margin-bottom: 20px;
|
| 192 |
+
display: flex;
|
| 193 |
+
justify-content: space-between;
|
| 194 |
+
align-items: center;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.status-indicator {
|
| 198 |
+
display: flex;
|
| 199 |
+
align-items: center;
|
| 200 |
+
gap: 8px;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
.status-dot {
|
| 204 |
+
width: 10px;
|
| 205 |
+
height: 10px;
|
| 206 |
+
border-radius: 50%;
|
| 207 |
+
background: #28a745;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.status-dot.loading {
|
| 211 |
+
background: #ffc107;
|
| 212 |
+
animation: pulse 1.5s infinite;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.status-dot.error {
|
| 216 |
+
background: #dc3545;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
@keyframes pulse {
|
| 220 |
+
0%, 100% { opacity: 1; }
|
| 221 |
+
50% { opacity: 0.5; }
|
| 222 |
+
}
|
| 223 |
+
</style>
|
| 224 |
+
</head>
|
| 225 |
+
<body>
|
| 226 |
+
<div class="container">
|
| 227 |
+
<div class="header">
|
| 228 |
+
<h1>🏆 PicExam</h1>
|
| 229 |
+
<p>基于 Qwen-VL 的智能图像理解系统</p>
|
| 230 |
+
<div style="margin-top: 15px; font-size: 0.9em;">
|
| 231 |
+
<a href="/docs" style="color: white; text-decoration: none; margin-right: 15px;">📚 API 文档</a>
|
| 232 |
+
<a href="/" style="color: white; text-decoration: none; margin-right: 15px;">🔗 API 端点</a>
|
| 233 |
+
<a href="/memory_status" style="color: white; text-decoration: none;">💾 内存状态</a>
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
|
| 237 |
+
<div class="content">
|
| 238 |
+
<div class="status-bar">
|
| 239 |
+
<div class="status-indicator">
|
| 240 |
+
<div class="status-dot" id="statusDot"></div>
|
| 241 |
+
<span id="statusText">检查服务状态...</span>
|
| 242 |
+
</div>
|
| 243 |
+
<button onclick="checkStatus()" style="background: none; border: 1px solid #ddd; padding: 5px 10px; border-radius: 5px; cursor: pointer;">刷新</button>
|
| 244 |
+
</div>
|
| 245 |
+
|
| 246 |
+
<form id="uploadForm">
|
| 247 |
+
<div class="form-group">
|
| 248 |
+
<label for="imageFile">选择图片</label>
|
| 249 |
+
<div class="upload-area" id="uploadArea">
|
| 250 |
+
<div class="upload-icon">📷</div>
|
| 251 |
+
<p>点击选择图片或拖拽图片到此处</p>
|
| 252 |
+
<p style="font-size: 0.9em; color: #666; margin-top: 10px;">支持 JPG, PNG, WebP 格式</p>
|
| 253 |
+
</div>
|
| 254 |
+
<input type="file" id="imageFile" accept="image/*" style="display: none;">
|
| 255 |
+
<img id="previewImage" class="preview-image" style="display: none;">
|
| 256 |
+
</div>
|
| 257 |
+
|
| 258 |
+
<div class="form-group">
|
| 259 |
+
<label for="question">问题描述</label>
|
| 260 |
+
<textarea id="question" placeholder="请输入您想问的关于图片的问题,例如:请描述这张图片的内容、图片中有什么物体、图片的颜色如何等...">请描述这张图片的内容</textarea>
|
| 261 |
+
</div>
|
| 262 |
+
|
| 263 |
+
<button type="submit" class="btn" id="submitBtn">
|
| 264 |
+
🔍 分析图片
|
| 265 |
+
</button>
|
| 266 |
+
</form>
|
| 267 |
+
|
| 268 |
+
<div class="loading" id="loading">
|
| 269 |
+
<div class="spinner"></div>
|
| 270 |
+
<p>正在分析图片,请稍候...</p>
|
| 271 |
+
</div>
|
| 272 |
+
|
| 273 |
+
<div id="result" style="display: none;"></div>
|
| 274 |
+
|
| 275 |
+
<!-- API 测试区域 -->
|
| 276 |
+
<div style="margin-top: 40px; padding-top: 30px; border-top: 2px solid #eee;">
|
| 277 |
+
<h2 style="color: #333; margin-bottom: 20px;">🔧 API 测试工具</h2>
|
| 278 |
+
|
| 279 |
+
<div style="background: #f8f9fa; padding: 20px; border-radius: 10px; margin-bottom: 20px;">
|
| 280 |
+
<h3 style="color: #555; margin-bottom: 15px;">JSON API 测试 (/analyze)</h3>
|
| 281 |
+
<div style="margin-bottom: 15px;">
|
| 282 |
+
<label style="display: block; margin-bottom: 5px; font-weight: 600;">提示词:</label>
|
| 283 |
+
<input type="text" id="jsonPrompt" value="请详细描述这张图片的内容" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 5px;">
|
| 284 |
+
</div>
|
| 285 |
+
<button onclick="testJsonAPI()" style="background: #28a745; color: white; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer;">测试 JSON API</button>
|
| 286 |
+
<div id="jsonResult" style="margin-top: 15px; display: none;"></div>
|
| 287 |
+
</div>
|
| 288 |
+
|
| 289 |
+
<div style="background: #f8f9fa; padding: 20px; border-radius: 10px;">
|
| 290 |
+
<h3 style="color: #555; margin-bottom: 15px;">API 端点信息</h3>
|
| 291 |
+
<button onclick="showAPIInfo()" style="background: #17a2b8; color: white; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer;">获取 API 信息</button>
|
| 292 |
+
<div id="apiInfo" style="margin-top: 15px; display: none;"></div>
|
| 293 |
+
</div>
|
| 294 |
+
</div>
|
| 295 |
+
</div>
|
| 296 |
+
</div>
|
| 297 |
+
|
| 298 |
+
<script>
|
| 299 |
+
// 检查服务状态
|
| 300 |
+
async function checkStatus() {
|
| 301 |
+
const statusDot = document.getElementById('statusDot');
|
| 302 |
+
const statusText = document.getElementById('statusText');
|
| 303 |
+
|
| 304 |
+
statusDot.className = 'status-dot loading';
|
| 305 |
+
statusText.textContent = '检查中...';
|
| 306 |
+
|
| 307 |
+
try {
|
| 308 |
+
const response = await fetch('/');
|
| 309 |
+
const data = await response.json();
|
| 310 |
+
|
| 311 |
+
if (data.model_loaded) {
|
| 312 |
+
statusDot.className = 'status-dot';
|
| 313 |
+
statusText.textContent = '服务正常,模型已加载';
|
| 314 |
+
} else {
|
| 315 |
+
statusDot.className = 'status-dot error';
|
| 316 |
+
statusText.textContent = '服务运行中,模型加载中...';
|
| 317 |
+
}
|
| 318 |
+
} catch (error) {
|
| 319 |
+
statusDot.className = 'status-dot error';
|
| 320 |
+
statusText.textContent = '服务连接失败';
|
| 321 |
+
}
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
// 页面加载时检查状态
|
| 325 |
+
window.addEventListener('load', checkStatus);
|
| 326 |
+
|
| 327 |
+
// 文件上传处理
|
| 328 |
+
const uploadArea = document.getElementById('uploadArea');
|
| 329 |
+
const fileInput = document.getElementById('imageFile');
|
| 330 |
+
const previewImage = document.getElementById('previewImage');
|
| 331 |
+
|
| 332 |
+
uploadArea.addEventListener('click', () => fileInput.click());
|
| 333 |
+
|
| 334 |
+
uploadArea.addEventListener('dragover', (e) => {
|
| 335 |
+
e.preventDefault();
|
| 336 |
+
uploadArea.classList.add('dragover');
|
| 337 |
+
});
|
| 338 |
+
|
| 339 |
+
uploadArea.addEventListener('dragleave', () => {
|
| 340 |
+
uploadArea.classList.remove('dragover');
|
| 341 |
+
});
|
| 342 |
+
|
| 343 |
+
uploadArea.addEventListener('drop', (e) => {
|
| 344 |
+
e.preventDefault();
|
| 345 |
+
uploadArea.classList.remove('dragover');
|
| 346 |
+
|
| 347 |
+
const files = e.dataTransfer.files;
|
| 348 |
+
if (files.length > 0) {
|
| 349 |
+
fileInput.files = files;
|
| 350 |
+
handleFileSelect();
|
| 351 |
+
}
|
| 352 |
+
});
|
| 353 |
+
|
| 354 |
+
fileInput.addEventListener('change', handleFileSelect);
|
| 355 |
+
|
| 356 |
+
function handleFileSelect() {
|
| 357 |
+
const file = fileInput.files[0];
|
| 358 |
+
if (file) {
|
| 359 |
+
const reader = new FileReader();
|
| 360 |
+
reader.onload = (e) => {
|
| 361 |
+
previewImage.src = e.target.result;
|
| 362 |
+
previewImage.style.display = 'block';
|
| 363 |
+
uploadArea.innerHTML = `
|
| 364 |
+
<div class="upload-icon">✅</div>
|
| 365 |
+
<p>已选择: ${file.name}</p>
|
| 366 |
+
<p style="font-size: 0.9em; color: #666;">点击重新选择</p>
|
| 367 |
+
`;
|
| 368 |
+
};
|
| 369 |
+
reader.readAsDataURL(file);
|
| 370 |
+
}
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
// 表单提交处理
|
| 374 |
+
document.getElementById('uploadForm').addEventListener('submit', async (e) => {
|
| 375 |
+
e.preventDefault();
|
| 376 |
+
|
| 377 |
+
const file = fileInput.files[0];
|
| 378 |
+
const question = document.getElementById('question').value;
|
| 379 |
+
|
| 380 |
+
if (!file) {
|
| 381 |
+
alert('请先选择一张图片');
|
| 382 |
+
return;
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
const submitBtn = document.getElementById('submitBtn');
|
| 386 |
+
const loading = document.getElementById('loading');
|
| 387 |
+
const result = document.getElementById('result');
|
| 388 |
+
|
| 389 |
+
// 显示加载状态
|
| 390 |
+
submitBtn.disabled = true;
|
| 391 |
+
loading.style.display = 'block';
|
| 392 |
+
result.style.display = 'none';
|
| 393 |
+
|
| 394 |
+
try {
|
| 395 |
+
const formData = new FormData();
|
| 396 |
+
formData.append('image', file);
|
| 397 |
+
formData.append('question', question);
|
| 398 |
+
|
| 399 |
+
const response = await fetch('/analyze_image', {
|
| 400 |
+
method: 'POST',
|
| 401 |
+
body: formData
|
| 402 |
+
});
|
| 403 |
+
|
| 404 |
+
const data = await response.json();
|
| 405 |
+
|
| 406 |
+
if (data.success) {
|
| 407 |
+
result.innerHTML = `
|
| 408 |
+
<div class="result">
|
| 409 |
+
<h3>📝 分析结果</h3>
|
| 410 |
+
<div class="result-content">
|
| 411 |
+
<p><strong>问题:</strong> ${data.question}</p>
|
| 412 |
+
<p><strong>回答:</strong> ${data.answer}</p>
|
| 413 |
+
<p><strong>图片信息:</strong> ${data.image_info.filename} (${data.image_info.size})</p>
|
| 414 |
+
</div>
|
| 415 |
+
</div>
|
| 416 |
+
`;
|
| 417 |
+
} else {
|
| 418 |
+
throw new Error(data.error || '分析失败');
|
| 419 |
+
}
|
| 420 |
+
} catch (error) {
|
| 421 |
+
result.innerHTML = `
|
| 422 |
+
<div class="result error">
|
| 423 |
+
<h3>❌ 分析失败</h3>
|
| 424 |
+
<div class="result-content">
|
| 425 |
+
<p>${error.message}</p>
|
| 426 |
+
</div>
|
| 427 |
+
</div>
|
| 428 |
+
`;
|
| 429 |
+
} finally {
|
| 430 |
+
submitBtn.disabled = false;
|
| 431 |
+
loading.style.display = 'none';
|
| 432 |
+
result.style.display = 'block';
|
| 433 |
+
}
|
| 434 |
+
});
|
| 435 |
+
|
| 436 |
+
// JSON API 测试
|
| 437 |
+
async function testJsonAPI() {
|
| 438 |
+
const file = fileInput.files[0];
|
| 439 |
+
const prompt = document.getElementById('jsonPrompt').value;
|
| 440 |
+
const resultDiv = document.getElementById('jsonResult');
|
| 441 |
+
|
| 442 |
+
if (!file) {
|
| 443 |
+
alert('请先选择一张图片');
|
| 444 |
+
return;
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
try {
|
| 448 |
+
// 将图片转换为 base64
|
| 449 |
+
const base64 = await fileToBase64(file);
|
| 450 |
+
|
| 451 |
+
const requestData = {
|
| 452 |
+
image: base64,
|
| 453 |
+
prompt: prompt
|
| 454 |
+
};
|
| 455 |
+
|
| 456 |
+
resultDiv.innerHTML = '<p>🔄 正在调用 JSON API...</p>';
|
| 457 |
+
resultDiv.style.display = 'block';
|
| 458 |
+
|
| 459 |
+
const response = await fetch('/analyze', {
|
| 460 |
+
method: 'POST',
|
| 461 |
+
headers: {
|
| 462 |
+
'Content-Type': 'application/json'
|
| 463 |
+
},
|
| 464 |
+
body: JSON.stringify(requestData)
|
| 465 |
+
});
|
| 466 |
+
|
| 467 |
+
const data = await response.json();
|
| 468 |
+
|
| 469 |
+
if (data.success) {
|
| 470 |
+
resultDiv.innerHTML = `
|
| 471 |
+
<div style="background: #d4edda; border: 1px solid #c3e6cb; padding: 15px; border-radius: 5px;">
|
| 472 |
+
<h4 style="color: #155724; margin-bottom: 10px;">✅ JSON API 调用成功</h4>
|
| 473 |
+
<p><strong>提示词:</strong> ${data.prompt}</p>
|
| 474 |
+
<p><strong>响应:</strong> ${data.response}</p>
|
| 475 |
+
<p><strong>处理时间:</strong> ${data.processing_time.toFixed(2)}秒</p>
|
| 476 |
+
<p><strong>图片信息:</strong> ${data.image_info.size} (${data.image_info.mode})</p>
|
| 477 |
+
</div>
|
| 478 |
+
`;
|
| 479 |
+
} else {
|
| 480 |
+
resultDiv.innerHTML = `
|
| 481 |
+
<div style="background: #f8d7da; border: 1px solid #f5c6cb; padding: 15px; border-radius: 5px;">
|
| 482 |
+
<h4 style="color: #721c24; margin-bottom: 10px;">❌ JSON API 调用失败</h4>
|
| 483 |
+
<p><strong>错误:</strong> ${data.error}</p>
|
| 484 |
+
</div>
|
| 485 |
+
`;
|
| 486 |
+
}
|
| 487 |
+
} catch (error) {
|
| 488 |
+
resultDiv.innerHTML = `
|
| 489 |
+
<div style="background: #f8d7da; border: 1px solid #f5c6cb; padding: 15px; border-radius: 5px;">
|
| 490 |
+
<h4 style="color: #721c24; margin-bottom: 10px;">❌ 请求失败</h4>
|
| 491 |
+
<p><strong>错误:</strong> ${error.message}</p>
|
| 492 |
+
</div>
|
| 493 |
+
`;
|
| 494 |
+
}
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
// 显示 API 信息
|
| 498 |
+
async function showAPIInfo() {
|
| 499 |
+
const infoDiv = document.getElementById('apiInfo');
|
| 500 |
+
|
| 501 |
+
try {
|
| 502 |
+
infoDiv.innerHTML = '<p>🔄 获取 API 信息...</p>';
|
| 503 |
+
infoDiv.style.display = 'block';
|
| 504 |
+
|
| 505 |
+
const response = await fetch('/');
|
| 506 |
+
const data = await response.json();
|
| 507 |
+
|
| 508 |
+
let endpointsHtml = '';
|
| 509 |
+
for (const [endpoint, info] of Object.entries(data.endpoints)) {
|
| 510 |
+
endpointsHtml += `
|
| 511 |
+
<div style="margin-bottom: 15px; padding: 10px; background: white; border-radius: 5px; border-left: 3px solid #667eea;">
|
| 512 |
+
<h5 style="color: #333; margin-bottom: 5px;">${endpoint}</h5>
|
| 513 |
+
<p style="color: #666; margin-bottom: 5px;">${info.description}</p>
|
| 514 |
+
${info.example ? `<code style="background: #f1f1f1; padding: 2px 5px; border-radius: 3px; font-size: 0.9em;">${info.example}</code>` : ''}
|
| 515 |
+
</div>
|
| 516 |
+
`;
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
infoDiv.innerHTML = `
|
| 520 |
+
<div style="background: #e7f3ff; border: 1px solid #b3d9ff; padding: 15px; border-radius: 5px;">
|
| 521 |
+
<h4 style="color: #0056b3; margin-bottom: 15px;">📋 API 端点信息</h4>
|
| 522 |
+
<p><strong>服务:</strong> ${data.service}</p>
|
| 523 |
+
<p><strong>版本:</strong> ${data.version}</p>
|
| 524 |
+
<p><strong>模型:</strong> ${data.model}</p>
|
| 525 |
+
<p><strong>状态:</strong> ${data.status.model_loaded ? '✅ 模型已加载' : '⏳ 模型加载中'}</p>
|
| 526 |
+
<hr style="margin: 15px 0; border: none; border-top: 1px solid #ccc;">
|
| 527 |
+
<h5 style="color: #333; margin-bottom: 10px;">可用端点:</h5>
|
| 528 |
+
${endpointsHtml}
|
| 529 |
+
</div>
|
| 530 |
+
`;
|
| 531 |
+
} catch (error) {
|
| 532 |
+
infoDiv.innerHTML = `
|
| 533 |
+
<div style="background: #f8d7da; border: 1px solid #f5c6cb; padding: 15px; border-radius: 5px;">
|
| 534 |
+
<h4 style="color: #721c24; margin-bottom: 10px;">❌ 获取 API 信息失败</h4>
|
| 535 |
+
<p><strong>错误:</strong> ${error.message}</p>
|
| 536 |
+
</div>
|
| 537 |
+
`;
|
| 538 |
+
}
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
// 文件转 base64 工具函数
|
| 542 |
+
function fileToBase64(file) {
|
| 543 |
+
return new Promise((resolve, reject) => {
|
| 544 |
+
const reader = new FileReader();
|
| 545 |
+
reader.readAsDataURL(file);
|
| 546 |
+
reader.onload = () => resolve(reader.result);
|
| 547 |
+
reader.onerror = error => reject(error);
|
| 548 |
+
});
|
| 549 |
+
}
|
| 550 |
+
</script>
|
| 551 |
+
</body>
|
| 552 |
+
</html>
|
test_api.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
测试 Qwen-VL PicExam API 的脚本
|
| 4 |
+
用于验证模型加载和推理功能
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import requests
|
| 8 |
+
import base64
|
| 9 |
+
import json
|
| 10 |
+
import time
|
| 11 |
+
from PIL import Image
|
| 12 |
+
import io
|
| 13 |
+
|
| 14 |
+
def test_health_check():
|
| 15 |
+
"""测试健康检查接口"""
|
| 16 |
+
print("🔍 测试健康检查接口...")
|
| 17 |
+
try:
|
| 18 |
+
response = requests.get("http://localhost:7860/")
|
| 19 |
+
print(f"状态码: {response.status_code}")
|
| 20 |
+
print(f"响应: {response.json()}")
|
| 21 |
+
return response.status_code == 200
|
| 22 |
+
except Exception as e:
|
| 23 |
+
print(f"❌ 健康检查失败: {e}")
|
| 24 |
+
return False
|
| 25 |
+
|
| 26 |
+
def test_memory_status():
|
| 27 |
+
"""测试内存状态接口"""
|
| 28 |
+
print("\n🔍 测试内存状态接口...")
|
| 29 |
+
try:
|
| 30 |
+
response = requests.get("http://localhost:7860/memory_status")
|
| 31 |
+
print(f"状态码: {response.status_code}")
|
| 32 |
+
data = response.json()
|
| 33 |
+
print(f"系统内存: {data['system_memory']['used_gb']:.2f}GB / {data['system_memory']['total_gb']:.2f}GB ({data['system_memory']['percent']:.1f}%)")
|
| 34 |
+
print(f"模型已加载: {data['model_loaded']}")
|
| 35 |
+
print(f"内存使用正常: {data['recommendations']['memory_usage_ok']}")
|
| 36 |
+
return response.status_code == 200
|
| 37 |
+
except Exception as e:
|
| 38 |
+
print(f"❌ 内存状态检查失败: {e}")
|
| 39 |
+
return False
|
| 40 |
+
|
| 41 |
+
def create_test_image():
|
| 42 |
+
"""创建一个简单的测试图片"""
|
| 43 |
+
# 创建一个简单的彩色图片
|
| 44 |
+
img = Image.new('RGB', (200, 200), color='red')
|
| 45 |
+
|
| 46 |
+
# 添加一些简单的图形
|
| 47 |
+
from PIL import ImageDraw
|
| 48 |
+
draw = ImageDraw.Draw(img)
|
| 49 |
+
draw.rectangle([50, 50, 150, 150], fill='blue')
|
| 50 |
+
draw.ellipse([75, 75, 125, 125], fill='yellow')
|
| 51 |
+
|
| 52 |
+
return img
|
| 53 |
+
|
| 54 |
+
def image_to_base64(image):
|
| 55 |
+
"""将 PIL 图片转换为 base64 字符串"""
|
| 56 |
+
buffer = io.BytesIO()
|
| 57 |
+
image.save(buffer, format='PNG')
|
| 58 |
+
img_str = base64.b64encode(buffer.getvalue()).decode()
|
| 59 |
+
return f"data:image/png;base64,{img_str}"
|
| 60 |
+
|
| 61 |
+
def test_image_analysis():
|
| 62 |
+
"""测试图片分析功能"""
|
| 63 |
+
print("\n🔍 测试图片分析功能...")
|
| 64 |
+
|
| 65 |
+
# 创建测试图片
|
| 66 |
+
test_img = create_test_image()
|
| 67 |
+
|
| 68 |
+
# 保存为临时文件
|
| 69 |
+
test_img.save("test_image.png")
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
# 测试文件上传接口
|
| 73 |
+
print("测试文件上传接口...")
|
| 74 |
+
with open("test_image.png", "rb") as f:
|
| 75 |
+
files = {"image": ("test_image.png", f, "image/png")}
|
| 76 |
+
data = {"question": "请描述这张图片中的颜色和形状"}
|
| 77 |
+
|
| 78 |
+
start_time = time.time()
|
| 79 |
+
response = requests.post("http://localhost:7860/analyze_image", files=files, data=data)
|
| 80 |
+
end_time = time.time()
|
| 81 |
+
|
| 82 |
+
print(f"状态码: {response.status_code}")
|
| 83 |
+
print(f"推理时间: {end_time - start_time:.2f}秒")
|
| 84 |
+
|
| 85 |
+
if response.status_code == 200:
|
| 86 |
+
result = response.json()
|
| 87 |
+
print(f"问题: {result['question']}")
|
| 88 |
+
print(f"回答: {result['answer']}")
|
| 89 |
+
print(f"图片信息: {result['image_info']}")
|
| 90 |
+
return True
|
| 91 |
+
else:
|
| 92 |
+
print(f"❌ 请求失败: {response.text}")
|
| 93 |
+
return False
|
| 94 |
+
|
| 95 |
+
except Exception as e:
|
| 96 |
+
print(f"❌ 图片分析测试失败: {e}")
|
| 97 |
+
return False
|
| 98 |
+
|
| 99 |
+
def test_base64_analysis():
|
| 100 |
+
"""测试 base64 图片分析功能"""
|
| 101 |
+
print("\n🔍 测试 base64 图片分析功能...")
|
| 102 |
+
|
| 103 |
+
try:
|
| 104 |
+
# 创建测试图片并转换为 base64
|
| 105 |
+
test_img = create_test_image()
|
| 106 |
+
img_base64 = image_to_base64(test_img)
|
| 107 |
+
|
| 108 |
+
data = {
|
| 109 |
+
"image_base64": img_base64,
|
| 110 |
+
"question": "这张图片中有什么几何形状?"
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
start_time = time.time()
|
| 114 |
+
response = requests.post("http://localhost:7860/analyze_image_base64", data=data)
|
| 115 |
+
end_time = time.time()
|
| 116 |
+
|
| 117 |
+
print(f"状态码: {response.status_code}")
|
| 118 |
+
print(f"推理时间: {end_time - start_time:.2f}秒")
|
| 119 |
+
|
| 120 |
+
if response.status_code == 200:
|
| 121 |
+
result = response.json()
|
| 122 |
+
print(f"问题: {result['question']}")
|
| 123 |
+
print(f"回答: {result['answer']}")
|
| 124 |
+
print(f"图片信息: {result['image_info']}")
|
| 125 |
+
return True
|
| 126 |
+
else:
|
| 127 |
+
print(f"❌ 请求失败: {response.text}")
|
| 128 |
+
return False
|
| 129 |
+
|
| 130 |
+
except Exception as e:
|
| 131 |
+
print(f"❌ base64 图片分析测试失败: {e}")
|
| 132 |
+
return False
|
| 133 |
+
|
| 134 |
+
def test_cache_clear():
|
| 135 |
+
"""测试缓存清理功能"""
|
| 136 |
+
print("\n🔍 测试缓存清理功能...")
|
| 137 |
+
try:
|
| 138 |
+
response = requests.post("http://localhost:7860/clear_cache")
|
| 139 |
+
print(f"状态码: {response.status_code}")
|
| 140 |
+
print(f"响应: {response.json()}")
|
| 141 |
+
return response.status_code == 200
|
| 142 |
+
except Exception as e:
|
| 143 |
+
print(f"❌ 缓存清理测试失败: {e}")
|
| 144 |
+
return False
|
| 145 |
+
|
| 146 |
+
def main():
|
| 147 |
+
"""主测试函数"""
|
| 148 |
+
print("🚀 开始测试 Qwen-VL PicExam API")
|
| 149 |
+
print("=" * 50)
|
| 150 |
+
|
| 151 |
+
# 等待服务启动
|
| 152 |
+
print("⏳ 等待服务启动...")
|
| 153 |
+
time.sleep(5)
|
| 154 |
+
|
| 155 |
+
tests = [
|
| 156 |
+
("健康检查", test_health_check),
|
| 157 |
+
("内存状态", test_memory_status),
|
| 158 |
+
("图片分析(文件上传)", test_image_analysis),
|
| 159 |
+
("图片分析(base64)", test_base64_analysis),
|
| 160 |
+
("缓存清理", test_cache_clear),
|
| 161 |
+
]
|
| 162 |
+
|
| 163 |
+
results = []
|
| 164 |
+
for test_name, test_func in tests:
|
| 165 |
+
try:
|
| 166 |
+
result = test_func()
|
| 167 |
+
results.append((test_name, result))
|
| 168 |
+
if result:
|
| 169 |
+
print(f"✅ {test_name} 测试通过")
|
| 170 |
+
else:
|
| 171 |
+
print(f"❌ {test_name} 测试失败")
|
| 172 |
+
except Exception as e:
|
| 173 |
+
print(f"❌ {test_name} 测试异常: {e}")
|
| 174 |
+
results.append((test_name, False))
|
| 175 |
+
|
| 176 |
+
print("-" * 30)
|
| 177 |
+
|
| 178 |
+
# 总结
|
| 179 |
+
print("\n📊 测试结果总结:")
|
| 180 |
+
passed = sum(1 for _, result in results if result)
|
| 181 |
+
total = len(results)
|
| 182 |
+
|
| 183 |
+
for test_name, result in results:
|
| 184 |
+
status = "✅ 通过" if result else "❌ 失败"
|
| 185 |
+
print(f" {test_name}: {status}")
|
| 186 |
+
|
| 187 |
+
print(f"\n总计: {passed}/{total} 测试通过")
|
| 188 |
+
|
| 189 |
+
if passed == total:
|
| 190 |
+
print("🎉 所有测试都通过了!API 运行正常。")
|
| 191 |
+
else:
|
| 192 |
+
print("⚠️ 部分测试失败,请检查日志。")
|
| 193 |
+
|
| 194 |
+
if __name__ == "__main__":
|
| 195 |
+
main()
|