Compare commits
24 Commits
9e6e9f98b6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 53e8fbb4dd | |||
| f7c73fa57e | |||
| bad6bfa34b | |||
| 054e720e39 | |||
| f8e94328a7 | |||
| aee6f8804f | |||
| 765a0aebdc | |||
| dc5a02f4ec | |||
| 4f6d7d9035 | |||
| 4667021944 | |||
| 06f2b2928b | |||
| 2d315948a2 | |||
| 34cbeb69c3 | |||
| 0ab6f52525 | |||
|
|
f6c361dd13 | ||
|
|
a4475e743d | ||
|
|
4659802f7a | ||
|
|
c3f7e93563 | ||
| 0f72cf7917 | |||
| b13f6df90e | |||
| d9db1b76db | |||
| 8ee3318ad7 | |||
| a5c5071529 | |||
| 99968f25ae |
498
README.md
498
README.md
@@ -1,69 +1,50 @@
|
||||
# SAM 3: Segment Anything with Concepts
|
||||
# 量迹AI · SAM3「分割一切」视觉分割服务
|
||||
|
||||
Meta Superintelligence Labs
|
||||
|
||||
[Nicolas Carion](https://www.nicolascarion.com/)\*,
|
||||
[Laura Gustafson](https://scholar.google.com/citations?user=c8IpF9gAAAAJ&hl=en)\*,
|
||||
[Yuan-Ting Hu](https://scholar.google.com/citations?user=E8DVVYQAAAAJ&hl=en)\*,
|
||||
[Shoubhik Debnath](https://scholar.google.com/citations?user=fb6FOfsAAAAJ&hl=en)\*,
|
||||
[Ronghang Hu](https://ronghanghu.com/)\*,
|
||||
[Didac Suris](https://www.didacsuris.com/)\*,
|
||||
[Chaitanya Ryali](https://scholar.google.com/citations?user=4LWx24UAAAAJ&hl=en)\*,
|
||||
[Kalyan Vasudev Alwala](https://scholar.google.co.in/citations?user=m34oaWEAAAAJ&hl=en)\*,
|
||||
[Haitham Khedr](https://hkhedr.com/)\*, Andrew Huang,
|
||||
[Jie Lei](https://jayleicn.github.io/),
|
||||
[Tengyu Ma](https://scholar.google.com/citations?user=VeTSl0wAAAAJ&hl=en),
|
||||
[Baishan Guo](https://scholar.google.com/citations?user=BC5wDu8AAAAJ&hl=en),
|
||||
Arpit Kalla, [Markus Marks](https://damaggu.github.io/),
|
||||
[Joseph Greer](https://scholar.google.com/citations?user=guL96CkAAAAJ&hl=en),
|
||||
Meng Wang, [Peize Sun](https://peizesun.github.io/),
|
||||
[Roman Rädle](https://scholar.google.com/citations?user=Tpt57v0AAAAJ&hl=en),
|
||||
[Triantafyllos Afouras](https://www.robots.ox.ac.uk/~afourast/),
|
||||
[Effrosyni Mavroudi](https://scholar.google.com/citations?user=vYRzGGEAAAAJ&hl=en),
|
||||
[Katherine Xu](https://k8xu.github.io/)°,
|
||||
[Tsung-Han Wu](https://patrickthwu.com/)°,
|
||||
[Yu Zhou](https://yu-bryan-zhou.github.io/)°,
|
||||
[Liliane Momeni](https://scholar.google.com/citations?user=Lb-KgVYAAAAJ&hl=en)°,
|
||||
[Rishi Hazra](https://rishihazra.github.io/)°,
|
||||
[Shuangrui Ding](https://mark12ding.github.io/)°,
|
||||
[Sagar Vaze](https://sgvaze.github.io/)°,
|
||||
[Francois Porcher](https://scholar.google.com/citations?user=LgHZ8hUAAAAJ&hl=en)°,
|
||||
[Feng Li](https://fengli-ust.github.io/)°,
|
||||
[Siyuan Li](https://siyuanliii.github.io/)°,
|
||||
[Aishwarya Kamath](https://ashkamath.github.io/)°,
|
||||
[Ho Kei Cheng](https://hkchengrex.com/)°,
|
||||
[Piotr Dollar](https://pdollar.github.io/)†,
|
||||
[Nikhila Ravi](https://nikhilaravi.com/)†,
|
||||
[Kate Saenko](https://ai.bu.edu/ksaenko.html)†,
|
||||
[Pengchuan Zhang](https://pzzhang.github.io/pzzhang/)†,
|
||||
[Christoph Feichtenhofer](https://feichtenhofer.github.io/)†
|
||||
# Admin Config
|
||||
ADMIN_PASSWORD = "admin_secure_password" # 可以根据需求修改
|
||||
HISTORY_FILE = "history.json"
|
||||
|
||||
\* core contributor, ° intern, † project lead, order is random within groups
|
||||
|
||||
[[`Paper`](https://ai.meta.com/research/publications/sam-3-segment-anything-with-concepts/)]
|
||||
[[`Project`](https://ai.meta.com/sam3)]
|
||||
[[`Demo`](https://segment-anything.com/)]
|
||||
[[`Blog`](https://ai.meta.com/blog/segment-anything-model-3/)]
|
||||
[[`BibTeX`](#citing-sam-3)]
|
||||
本项目在开源 SAM3(Segment Anything Model 3)能力之上,封装了面向业务的 **“分割一切”** 推理服务:通过 **FastAPI** 提供文本提示词驱动的图像分割接口,并扩展了 **塔罗牌分割/识别**、**人脸与头发分割 + 属性分析** 等场景能力。
|
||||
|
||||
 SAM 3 is a unified foundation model for promptable segmentation in images and videos. It can detect, segment, and track objects using text or visual prompts such as points, boxes, and masks. Compared to its predecessor [SAM 2](https://github.com/facebookresearch/sam2), SAM 3 introduces the ability to exhaustively segment all instances of an open-vocabulary concept specified by a short text phrase or exemplars. Unlike prior work, SAM 3 can handle a vastly larger set of open-vocabulary prompts. It achieves 75-80% of human performance on our new [SA-CO benchmark](https://github.com/facebookresearch/sam3?tab=readme-ov-file#sa-co-dataset) which contains 270K unique concepts, over 50 times more than existing benchmarks.
|
||||
本仓库定位为:**模型推理 + API 服务** 的可复用工程模板(适合在 MacOS 开发、服务器部署)。
|
||||
|
||||
This breakthrough is driven by an innovative data engine that has automatically annotated over 4 million unique concepts, creating the largest high-quality open-vocabulary segmentation dataset to date. In addition, SAM 3 introduces a new model architecture featuring a presence token that improves discrimination between closely related text prompts (e.g., “a player in white” vs. “a player in red”), as well as a decoupled detector–tracker design that minimizes task interference and scales efficiently with data.
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/dog.gif" width=380 />
|
||||
<img src="assets/player.gif" width=380 />
|
||||
</p>
|
||||
## 你能用它做什么
|
||||
|
||||
## Installation
|
||||
- 通用分割:输入图片 + 文本提示词(中文或英文),返回分割可视化结果图 URL
|
||||
- 分割子图导出:可选返回每个目标的独立裁剪图(支持透明背景抠图)
|
||||
- 塔罗牌:检测指定数量的塔罗牌 → 透视矫正裁剪 →(可选)调用多模态大模型识别牌名与正逆位
|
||||
- 人脸分析:分割人脸/头发区域 → 裁剪保存 →(可选)调用多模态大模型预测性别/年龄等属性
|
||||
|
||||
### Prerequisites
|
||||
对应实现文件:
|
||||
- API 服务入口:[fastAPI_tarot.py](fastAPI_tarot.py)
|
||||
- 人脸分析流程:[human_analysis_service.py](human_analysis_service.py)
|
||||
|
||||
- Python 3.12 or higher
|
||||
- PyTorch 2.7 or higher
|
||||
- CUDA-compatible GPU with CUDA 12.6 or higher
|
||||
---
|
||||
|
||||
1. **Create a new Conda environment:**
|
||||
## 项目结构(关键目录)
|
||||
|
||||
- sam3/:SAM3 模型核心代码(本仓库已内置,不依赖外部子模块)
|
||||
- static/results/:推理结果落盘目录(接口返回的 URL 指向这里)
|
||||
- assets/:示例素材、演示图片/视频
|
||||
- fastAPI_tarot.py:主要 API 服务(含鉴权、模型加载、推理、可视化与落盘)
|
||||
|
||||
---
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Python:建议 3.10+(SAM3 作为库最低 3.8+)
|
||||
- PyTorch:建议按你的设备(CPU/CUDA)安装匹配版本
|
||||
- 依赖:FastAPI/uvicorn 等(见 requirement.txt,另有 sam3 依赖见 pyproject.toml)
|
||||
|
||||
---
|
||||
|
||||
## 安装与准备
|
||||
|
||||
### 0) 创建虚拟环境(推荐)
|
||||
|
||||
```bash
|
||||
conda create -n sam3 python=3.12
|
||||
@@ -71,331 +52,178 @@ conda deactivate
|
||||
conda activate sam3
|
||||
```
|
||||
|
||||
### 0.1) 安装 PyTorch(按你的设备选择)
|
||||
|
||||
GPU(CUDA)环境请安装与你 CUDA 版本匹配的 PyTorch;CPU 环境可直接安装 CPU 版。
|
||||
|
||||
```bash
|
||||
pip install torch torchvision torchaudio
|
||||
```
|
||||
|
||||
### 1) 安装本仓库与依赖
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
pip install -r requirement.txt
|
||||
```
|
||||
|
||||
如果需要接口用到的第三方库(如 OpenCV、matplotlib、Pillow、requests、dashscope 等),请按你的实际场景安装(仓库中部分功能会依赖它们)。
|
||||
|
||||
### 2) 下载权重模型(必须)
|
||||
|
||||
> 说明:模型文件较大,建议预留充足磁盘空间。
|
||||
>
|
||||
> 默认将权重保存到 `./dir/`,可按需修改 `--local_dir`。
|
||||
>
|
||||
> 如需通过 Hugging Face 下载,也可以自行改用 `huggingface_hub` 的下载方式。
|
||||
|
||||
1.1 **下载权重模型:**
|
||||
|
||||
```bash
|
||||
pip install modelscope
|
||||
modelscope download --model facebook/sam3 sam3.pt --local_dir ./dir
|
||||
```
|
||||
|
||||
2. **Install PyTorch with CUDA support:**
|
||||
下载后会得到 `./dir/sam3.pt`。
|
||||
|
||||
```bash
|
||||
pip install torch==2.7.0 torchvision torchaudio --index-url https://download.pytorch.org/whl/cu126
|
||||
```
|
||||
### 3) 配置 SAM3 权重路径(必须)
|
||||
|
||||
3. **Clone the repository and install the package:**
|
||||
当前 API 服务会在启动时加载 SAM3 权重。你需要确保 `fastAPI_tarot.py` 里构建模型时使用了正确的 `checkpoint_path`。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/facebookresearch/sam3.git
|
||||
cd sam3
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
4. **Install additional dependencies for example notebooks or development:**
|
||||
|
||||
```bash
|
||||
# For running example notebooks
|
||||
pip install -e ".[notebooks]"
|
||||
|
||||
# For development
|
||||
pip install -e ".[train,dev]"
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
⚠️ Before using SAM 3, please request access to the checkpoints on the SAM 3
|
||||
Hugging Face [repo](https://huggingface.co/facebook/sam3). Once accepted, you
|
||||
need to be authenticated to download the checkpoints. You can do this by running
|
||||
the following [steps](https://huggingface.co/docs/huggingface_hub/en/quick-start#authentication)
|
||||
(e.g. `hf auth login` after generating an access token.)
|
||||
|
||||
### Basic Usage
|
||||
推荐做法:在 [fastAPI_tarot.py](fastAPI_tarot.py) 中将模型构建改为显式传入本地权重路径,例如:
|
||||
|
||||
```python
|
||||
import torch
|
||||
#################################### For Image ####################################
|
||||
from PIL import Image
|
||||
from sam3.model_builder import build_sam3_image_model
|
||||
from sam3.model.sam3_image_processor import Sam3Processor
|
||||
# Load the model
|
||||
model = build_sam3_image_model()
|
||||
processor = Sam3Processor(model)
|
||||
# Load an image
|
||||
image = Image.open("<YOUR_IMAGE_PATH.jpg>")
|
||||
inference_state = processor.set_image(image)
|
||||
# Prompt the model with text
|
||||
output = processor.set_text_prompt(state=inference_state, prompt="<YOUR_TEXT_PROMPT>")
|
||||
|
||||
# Get the masks, bounding boxes, and scores
|
||||
masks, boxes, scores = output["masks"], output["boxes"], output["scores"]
|
||||
|
||||
#################################### For Video ####################################
|
||||
|
||||
from sam3.model_builder import build_sam3_video_predictor
|
||||
|
||||
video_predictor = build_sam3_video_predictor()
|
||||
video_path = "<YOUR_VIDEO_PATH>" # a JPEG folder or an MP4 video file
|
||||
# Start a session
|
||||
response = video_predictor.handle_request(
|
||||
request=dict(
|
||||
type="start_session",
|
||||
resource_path=video_path,
|
||||
)
|
||||
)
|
||||
response = video_predictor.handle_request(
|
||||
request=dict(
|
||||
type="add_prompt",
|
||||
session_id=response["session_id"],
|
||||
frame_index=0, # Arbitrary frame index
|
||||
text="<YOUR_TEXT_PROMPT>",
|
||||
)
|
||||
)
|
||||
output = response["outputs"]
|
||||
model = build_sam3_image_model(checkpoint_path="./dir/sam3.pt")
|
||||
```
|
||||
|
||||
## Examples
|
||||
---
|
||||
|
||||
The `examples` directory contains notebooks demonstrating how to use SAM3 with
|
||||
various types of prompts:
|
||||
## 启动 API 服务(FastAPI)
|
||||
|
||||
- [`sam3_image_predictor_example.ipynb`](examples/sam3_image_predictor_example.ipynb)
|
||||
: Demonstrates how to prompt SAM 3 with text and visual box prompts on images.
|
||||
- [`sam3_video_predictor_example.ipynb`](examples/sam3_video_predictor_example.ipynb)
|
||||
: Demonstrates how to prompt SAM 3 with text prompts on videos, and doing
|
||||
further interactive refinements with points.
|
||||
- [`sam3_image_batched_inference.ipynb`](examples/sam3_image_batched_inference.ipynb)
|
||||
: Demonstrates how to run batched inference with SAM 3 on images.
|
||||
- [`sam3_agent.ipynb`](examples/sam3_agent.ipynb): Demonsterates the use of SAM
|
||||
3 Agent to segment complex text prompt on images.
|
||||
- [`saco_gold_silver_vis_example.ipynb`](examples/saco_gold_silver_vis_example.ipynb)
|
||||
: Shows a few examples from SA-Co image evaluation set.
|
||||
- [`saco_veval_vis_example.ipynb`](examples/saco_veval_vis_example.ipynb) :
|
||||
Shows a few examples from SA-Co video evaluation set.
|
||||
|
||||
There are additional notebooks in the examples directory that demonstrate how to
|
||||
use SAM 3 for interactive instance segmentation in images and videos (SAM 1/2
|
||||
tasks), or as a tool for an MLLM, and how to run evaluations on the SA-Co
|
||||
dataset.
|
||||
|
||||
To run the Jupyter notebook examples:
|
||||
服务定义在 [fastAPI_tarot.py](fastAPI_tarot.py) 中。你可以用 uvicorn 启动(示例命令仅供参考,按需调整 host/port):
|
||||
|
||||
```bash
|
||||
# Make sure you have the notebooks dependencies installed
|
||||
pip install -e ".[notebooks]"
|
||||
|
||||
# Start Jupyter notebook
|
||||
jupyter notebook examples/sam3_image_predictor_example.ipynb
|
||||
uvicorn fastAPI_tarot:app --host 127.0.0.1 --port 55600
|
||||
```
|
||||
|
||||
## Model
|
||||
启动后:
|
||||
- OpenAPI 文档:`http://127.0.0.1:55600/docs`
|
||||
- 静态结果目录:`http://127.0.0.1:55600/static/results/...`
|
||||
|
||||
SAM 3 consists of a detector and a tracker that share a vision encoder. It has 848M parameters. The
|
||||
detector is a DETR-based model conditioned on text, geometry, and image
|
||||
exemplars. The tracker inherits the SAM 2 transformer encoder-decoder
|
||||
architecture, supporting video segmentation and interactive refinement.
|
||||
---
|
||||
|
||||
## Image Results
|
||||
## 鉴权说明(API Key)
|
||||
|
||||
<div align="center">
|
||||
<table style="min-width: 80%; border: 2px solid #ddd; border-collapse: collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="3" style="border-right: 2px solid #ddd; padding: 12px 20px">Model</th>
|
||||
<th colspan="3" style="text-align: center; border-right: 2px solid #ddd; padding: 12px 20px">Instance Segmentation</th>
|
||||
<th colspan="5" style="text-align: center; padding: 12px 20px">Box Detection</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th colspan="2" style="text-align: center; border-right: 1px solid #eee; padding: 12px 20px">LVIS</th>
|
||||
<th style="text-align: center; border-right: 2px solid #ddd; padding: 12px 20px">SA-Co/Gold</th>
|
||||
<th colspan="2" style="text-align: center; border-right: 1px solid #eee; padding: 12px 20px">LVIS</th>
|
||||
<th colspan="2" style="text-align: center; border-right: 1px solid #eee; padding: 12px 20px">COCO</th>
|
||||
<th style="text-align: center; padding: 12px 20px">SA-Co/Gold</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style="text-align: center; padding: 12px 20px">cgF1</th>
|
||||
<th style="text-align: center; border-right: 1px solid #eee; padding: 12px 20px">AP</th>
|
||||
<th style="text-align: center; border-right: 2px solid #ddd; padding: 12px 20px">cgF1</th>
|
||||
<th style="text-align: center; padding: 12px 20px">cgF1</th>
|
||||
<th style="text-align: center; border-right: 1px solid #eee; padding: 12px 20px">AP</th>
|
||||
<th style="text-align: center; padding: 12px 20px">AP</th>
|
||||
<th style="text-align: center; border-right: 1px solid #eee; padding: 12px 20px">AP<sub>o</sub>
|
||||
</th>
|
||||
<th style="text-align: center; padding: 12px 20px">cgF1</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="border-right: 2px solid #ddd; padding: 10px 20px">Human</td>
|
||||
<td style="text-align: center; padding: 10px 20px">-</td>
|
||||
<td style="text-align: center; border-right: 1px solid #eee; padding: 10px 20px">-</td>
|
||||
<td style="text-align: center; border-right: 2px solid #ddd; padding: 10px 20px">72.8</td>
|
||||
<td style="text-align: center; padding: 10px 20px">-</td>
|
||||
<td style="text-align: center; border-right: 1px solid #eee; padding: 10px 20px">-</td>
|
||||
<td style="text-align: center; padding: 10px 20px">-</td>
|
||||
<td style="text-align: center; border-right: 1px solid #eee; padding: 10px 20px">-</td>
|
||||
<td style="text-align: center; padding: 10px 20px">74.0</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="border-right: 2px solid #ddd; padding: 10px 20px">OWLv2*</td>
|
||||
<td style="text-align: center; padding: 10px 20px; color: #999">29.3</td>
|
||||
<td style="text-align: center; border-right: 1px solid #eee; padding: 10px 20px; color: #999">43.4</td>
|
||||
<td style="text-align: center; border-right: 2px solid #ddd; padding: 10px 20px">24.6</td>
|
||||
<td style="text-align: center; padding: 10px 20px; color: #999">30.2</td>
|
||||
<td style="text-align: center; border-right: 1px solid #eee; padding: 10px 20px; color: #999">45.5</td>
|
||||
<td style="text-align: center; padding: 10px 20px">46.1</td>
|
||||
<td style="text-align: center; border-right: 1px solid #eee; padding: 10px 20px">23.9</td>
|
||||
<td style="text-align: center; padding: 10px 20px">24.5</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="border-right: 2px solid #ddd; padding: 10px 20px">DINO-X</td>
|
||||
<td style="text-align: center; padding: 10px 20px">-</td>
|
||||
<td style="text-align: center; border-right: 1px solid #eee; padding: 10px 20px">38.5</td>
|
||||
<td style="text-align: center; border-right: 2px solid #ddd; padding: 10px 20px">21.3</td>
|
||||
<td style="text-align: center; padding: 10px 20px">-</td>
|
||||
<td style="text-align: center; border-right: 1px solid #eee; padding: 10px 20px">52.4</td>
|
||||
<td style="text-align: center; padding: 10px 20px">56.0</td>
|
||||
<td style="text-align: center; border-right: 1px solid #eee; padding: 10px 20px">-</td>
|
||||
<td style="text-align: center; padding: 10px 20px">22.5</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="border-right: 2px solid #ddd; padding: 10px 20px">Gemini 2.5</td>
|
||||
<td style="text-align: center; padding: 10px 20px">13.4</td>
|
||||
<td style="text-align: center; border-right: 1px solid #eee; padding: 10px 20px">-</td>
|
||||
<td style="text-align: center; border-right: 2px solid #ddd; padding: 10px 20px">13.0</td>
|
||||
<td style="text-align: center; padding: 10px 20px">16.1</td>
|
||||
<td style="text-align: center; border-right: 1px solid #eee; padding: 10px 20px">-</td>
|
||||
<td style="text-align: center; padding: 10px 20px">-</td>
|
||||
<td style="text-align: center; border-right: 1px solid #eee; padding: 10px 20px">-</td>
|
||||
<td style="text-align: center; padding: 10px 20px">14.4</td>
|
||||
</tr>
|
||||
<tr style="border-top: 2px solid #b19c9cff">
|
||||
<td style="border-right: 2px solid #ddd; padding: 10px 20px">SAM 3</td>
|
||||
<td style="text-align: center; padding: 10px 20px">37.2</td>
|
||||
<td style="text-align: center; border-right: 1px solid #eee; padding: 10px 20px">48.5</td>
|
||||
<td style="text-align: center; border-right: 2px solid #ddd; padding: 10px 20px">54.1</td>
|
||||
<td style="text-align: center; padding: 10px 20px">40.6</td>
|
||||
<td style="text-align: center; border-right: 1px solid #eee; padding: 10px 20px">53.6</td>
|
||||
<td style="text-align: center; padding: 10px 20px">56.4</td>
|
||||
<td style="text-align: center; border-right: 1px solid #eee; padding: 10px 20px">55.7</td>
|
||||
<td style="text-align: center; padding: 10px 20px">55.7</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
所有接口都要求在请求 Header 中携带 API Key:
|
||||
|
||||
<p style="text-align: center; margin-top: 10px; font-size: 0.9em; color: #ddd;">* Partially trained on LVIS, AP<sub>o</sub> refers to COCO-O accuracy</p>
|
||||
- Header 名:`X-API-Key`
|
||||
- 值:请使用你服务端配置的 Key(建议用环境变量或配置文件管理,不要在代码里硬编码)
|
||||
|
||||
</div>
|
||||
---
|
||||
|
||||
## Video Results
|
||||
## API 接口一览
|
||||
|
||||
<div align="center">
|
||||
<table style="min-width: 80%; border: 2px solid #ddd; border-collapse: collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2" style="border-right: 2px solid #ddd; padding: 12px 20px">Model</th>
|
||||
<th colspan="2" style="text-align: center; border-right: 1px solid #eee; padding: 12px 20px">SA-V test</th>
|
||||
<th colspan="2" style="text-align: center; border-right: 1px solid #eee; padding: 12px 20px">YT-Temporal-1B test</th>
|
||||
<th colspan="2" style="text-align: center; border-right: 1px solid #eee; padding: 12px 20px">SmartGlasses test</th>
|
||||
<th style="text-align: center; border-right: 1px solid #eee; padding: 12px 20px">LVVIS test</th>
|
||||
<th style="text-align: center; padding: 12px 20px">BURST test</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style="text-align: center; padding: 12px 20px">cgF1</th>
|
||||
<th style="text-align: center; border-right: 1px solid #eee; padding: 12px 20px">pHOTA</th>
|
||||
<th style="text-align: center; padding: 12px 20px">cgF1</th>
|
||||
<th style="text-align: center; border-right: 1px solid #eee; padding: 12px 20px">pHOTA</th>
|
||||
<th style="text-align: center; padding: 12px 20px">cgF1</th>
|
||||
<th style="text-align: center; border-right: 1px solid #eee; padding: 12px 20px">pHOTA</th>
|
||||
<th style="text-align: center; border-right: 1px solid #eee; padding: 12px 20px">mAP</th>
|
||||
<th style="text-align: center; padding: 12px 20px">HOTA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="border-right: 2px solid #ddd; padding: 10px 20px">Human</td>
|
||||
<td style="text-align: center; padding: 10px 20px">53.1</td>
|
||||
<td style="text-align: center; border-right: 1px solid #eee; padding: 10px 20px">70.5</td>
|
||||
<td style="text-align: center; padding: 10px 20px">71.2</td>
|
||||
<td style="text-align: center; border-right: 1px solid #eee; padding: 10px 20px">78.4</td>
|
||||
<td style="text-align: center; padding: 10px 20px">58.5</td>
|
||||
<td style="text-align: center; border-right: 1px solid #eee; padding: 10px 20px">72.3</td>
|
||||
<td style="text-align: center; border-right: 1px solid #eee; padding: 10px 20px">-</td>
|
||||
<td style="text-align: center; padding: 10px 20px">-</td>
|
||||
</tr>
|
||||
<tr style="border-top: 2px solid #b19c9cff">
|
||||
<td style="border-right: 2px solid #ddd; padding: 10px 20px">SAM 3</td>
|
||||
<td style="text-align: center; padding: 10px 20px">30.3</td>
|
||||
<td style="text-align: center; border-right: 1px solid #eee; padding: 10px 20px">58.0</td>
|
||||
<td style="text-align: center; padding: 10px 20px">50.8</td>
|
||||
<td style="text-align: center; border-right: 1px solid #eee; padding: 10px 20px">69.9</td>
|
||||
<td style="text-align: center; padding: 10px 20px">36.4</td>
|
||||
<td style="text-align: center; border-right: 1px solid #eee; padding: 10px 20px">63.6</td>
|
||||
<td style="text-align: center; border-right: 1px solid #eee; padding: 10px 20px">36.3</td>
|
||||
<td style="text-align: center; padding: 10px 20px">44.5</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
以下接口均在 [fastAPI_tarot.py](fastAPI_tarot.py) 中实现,返回体为 JSON,并通过静态文件服务输出结果图片 URL。
|
||||
|
||||
## SA-Co Dataset
|
||||
### 1) POST /segment(通用分割)
|
||||
|
||||
We release 2 image benchmarks, [SA-Co/Gold](scripts/eval/gold/README.md) and
|
||||
[SA-Co/Silver](scripts/eval/silver/README.md), and a video benchmark
|
||||
[SA-Co/VEval](scripts/eval/veval/README.md). The datasets contain images (or videos) with annotated noun phrases. Each image/video and noun phrase pair is annotated with instance masks and unique IDs of each object matching the phrase. Phrases that have no matching objects (negative prompts) have no masks, shown in red font in the figure. See the linked READMEs for more details on how to download and run evaluations on the datasets.
|
||||
功能:对图片做文本提示词分割(中文会自动翻译为英文提示词)。
|
||||
|
||||
* HuggingFace host: [SA-Co/Gold](https://huggingface.co/datasets/facebook/SACo-Gold), [SA-Co/Silver](https://huggingface.co/datasets/facebook/SACo-Silver) and [SA-Co/VEval](https://huggingface.co/datasets/facebook/SACo-VEval)
|
||||
* Roboflow host: [SA-Co/Gold](https://universe.roboflow.com/sa-co-gold), [SA-Co/Silver](https://universe.roboflow.com/sa-co-silver) and [SA-Co/VEval](https://universe.roboflow.com/sa-co-veval)
|
||||
参数(表单):
|
||||
- prompt:提示词(必填,例:`cat` / `人` / `白色连衣裙`)
|
||||
- file:上传图片(二选一)
|
||||
- image_url:图片 URL(二选一)
|
||||
- save_segment_images:是否保存并返回每个目标的独立图片(默认 false)
|
||||
- cutout:导出子图时是否透明背景抠图(默认 false)
|
||||
- highlight:是否启用“周边变黑突出主体”的效果(默认 false)
|
||||
- confidence:置信度阈值(0.0-1.0,默认 0.7)
|
||||
|
||||

|
||||
返回(示例字段):
|
||||
- result_image_url:分割可视化结果图
|
||||
- detected_count:检测到的目标数量
|
||||
- segmented_images:可选的分割子图 URL 列表
|
||||
|
||||
## Development
|
||||
### 2) POST /segment_tarot(塔罗牌分割)
|
||||
|
||||
To set up the development environment:
|
||||
功能:检测塔罗牌并对每张牌做透视矫正与裁剪。
|
||||
|
||||
参数(表单):
|
||||
- file / image_url(二选一)
|
||||
- expected_count:期望严格检测到的塔罗牌数量(默认 3)
|
||||
|
||||
返回:
|
||||
- tarot_cards:每张牌的裁剪图 URL、是否被算法旋转矫正等信息
|
||||
- full_visualization:整体分割可视化图 URL(可选)
|
||||
|
||||
### 3) POST /recognize_tarot(塔罗牌全流程:分割 + 识别)
|
||||
|
||||
功能:在 `/segment_tarot` 基础上,进一步调用多模态大模型识别每张牌的名称与正逆位,并尝试识别牌阵。
|
||||
|
||||
说明:
|
||||
- 该流程依赖多模态大模型服务(例如 Qwen-VL / DashScope),请自行配置密钥与可用模型
|
||||
- 强烈建议将密钥通过环境变量注入,避免写入仓库
|
||||
|
||||
### 4) POST /segment_face(人脸/头发分割 + 属性分析)
|
||||
|
||||
功能:分割“face and hair”等区域,并可选调用多模态大模型进行性别/年龄等属性识别。
|
||||
|
||||
参数(表单):
|
||||
- file / image_url(二选一)
|
||||
- prompt:默认 `face and hair`(中文会尝试翻译)
|
||||
|
||||
---
|
||||
|
||||
## 请求示例(curl)
|
||||
|
||||
### 通用分割(上传图片)
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev,train]"
|
||||
curl -X POST "http://127.0.0.1:55600/segment" \
|
||||
-H "X-API-Key: <YOUR_API_KEY>" \
|
||||
-F "prompt=猫" \
|
||||
-F "file=@/path/to/image.jpg"
|
||||
```
|
||||
|
||||
To format the code:
|
||||
### 通用分割(图片 URL)
|
||||
|
||||
```bash
|
||||
ufmt format .
|
||||
curl -X POST "http://127.0.0.1:55600/segment" \
|
||||
-H "X-API-Key: <YOUR_API_KEY>" \
|
||||
-F "prompt=person" \
|
||||
-F "image_url=https://example.com/image.jpg"
|
||||
```
|
||||
|
||||
## Contributing
|
||||
---
|
||||
|
||||
See [contributing](CONTRIBUTING.md) and the
|
||||
[code of conduct](CODE_OF_CONDUCT.md).
|
||||
## 结果输出与落盘
|
||||
|
||||
## License
|
||||
- 结果图会写入:`static/results/`
|
||||
- 接口返回的 URL 形如:`/static/results/<filename>` 或 `/static/results/<request_id>/<filename>`
|
||||
- 服务可选开启后台清理任务,定期删除过期结果文件(见 `fastAPI_tarot.py` 末尾的环境变量配置逻辑)
|
||||
|
||||
This project is licensed under the SAM License - see the [LICENSE](LICENSE) file
|
||||
for details.
|
||||
---
|
||||
|
||||
## Acknowledgements
|
||||
## 常见问题
|
||||
|
||||
We would like to thank the following people for their contributions to the SAM 3 project: Alex He, Alexander Kirillov,
|
||||
Alyssa Newcomb, Ana Paula Kirschner Mofarrej, Andrea Madotto, Andrew Westbury, Ashley Gabriel, Azita Shokpour,
|
||||
Ben Samples, Bernie Huang, Carleigh Wood, Ching-Feng Yeh, Christian Puhrsch, Claudette Ward, Daniel Bolya,
|
||||
Daniel Li, Facundo Figueroa, Fazila Vhora, George Orlin, Hanzi Mao, Helen Klein, Hu Xu, Ida Cheng, Jake Kinney,
|
||||
Jiale Zhi, Jo Sampaio, Joel Schlosser, Justin Johnson, Kai Brown, Karen Bergan, Karla Martucci, Kenny Lehmann,
|
||||
Maddie Mintz, Mallika Malhotra, Matt Ward, Michelle Chan, Michelle Restrepo, Miranda Hartley, Muhammad Maaz,
|
||||
Nisha Deo, Peter Park, Phillip Thomas, Raghu Nayani, Rene Martinez Doehner, Robbie Adkins, Ross Girshik, Sasha
|
||||
Mitts, Shashank Jain, Spencer Whitehead, Ty Toledano, Valentin Gabeur, Vincent Cho, Vivian Lee, William Ngan,
|
||||
Xuehai He, Yael Yungster, Ziqi Pang, Ziyi Dou, Zoe Quake.
|
||||
### 1) 启动时报找不到权重文件?
|
||||
|
||||
## Citing SAM 3
|
||||
请确认:
|
||||
- 已按“下载权重模型”得到 `sam3.pt`
|
||||
- `fastAPI_tarot.py` 中 `build_sam3_image_model(checkpoint_path=...)` 指向正确路径
|
||||
|
||||
If you use SAM 3 or the SA-Co dataset in your research, please use the following BibTeX entry.
|
||||
### 2) 返回 401/403?
|
||||
|
||||
```bibtex
|
||||
@misc{carion2025sam3segmentconcepts,
|
||||
title={SAM 3: Segment Anything with Concepts},
|
||||
author={Nicolas Carion and Laura Gustafson and Yuan-Ting Hu and Shoubhik Debnath and Ronghang Hu and Didac Suris and Chaitanya Ryali and Kalyan Vasudev Alwala and Haitham Khedr and Andrew Huang and Jie Lei and Tengyu Ma and Baishan Guo and Arpit Kalla and Markus Marks and Joseph Greer and Meng Wang and Peize Sun and Roman Rädle and Triantafyllos Afouras and Effrosyni Mavroudi and Katherine Xu and Tsung-Han Wu and Yu Zhou and Liliane Momeni and Rishi Hazra and Shuangrui Ding and Sagar Vaze and Francois Porcher and Feng Li and Siyuan Li and Aishwarya Kamath and Ho Kei Cheng and Piotr Dollár and Nikhila Ravi and Kate Saenko and Pengchuan Zhang and Christoph Feichtenhofer},
|
||||
year={2025},
|
||||
eprint={2511.16719},
|
||||
archivePrefix={arXiv},
|
||||
primaryClass={cs.CV},
|
||||
url={https://arxiv.org/abs/2511.16719},
|
||||
}
|
||||
```
|
||||
请确认请求头是否带了 `X-API-Key`,且值与服务端配置一致。
|
||||
|
||||
---
|
||||
|
||||
# Admin Config
|
||||
ADMIN_PASSWORD = "admin_secure_password" # 可以根据需求修改
|
||||
HISTORY_FILE = "history.json"
|
||||
|
||||
API_KEY = "123quant-speed" # 可以根据需求修改
|
||||
|
||||
## 免责声明与致谢
|
||||
|
||||
- 本项目为量迹AI内部/业务化工程封装示例,**不是官方 SAM3 仓库**。
|
||||
- 模型与核心算法能力来自开源的 SAM3 实现与相关依赖,感谢社区贡献者与原作者团队。
|
||||
|
||||
191
fastAPI_nocom.py
191
fastAPI_nocom.py
@@ -1,191 +0,0 @@
|
||||
import os
|
||||
import uuid
|
||||
import requests
|
||||
from typing import Optional
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import torch
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Request, Depends, status
|
||||
from fastapi.security import APIKeyHeader
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import JSONResponse
|
||||
from PIL import Image
|
||||
|
||||
from sam3.model_builder import build_sam3_image_model
|
||||
from sam3.model.sam3_image_processor import Sam3Processor
|
||||
from sam3.visualization_utils import plot_results
|
||||
|
||||
# ------------------- 配置与路径 -------------------
|
||||
STATIC_DIR = "static"
|
||||
RESULT_IMAGE_DIR = os.path.join(STATIC_DIR, "results")
|
||||
os.makedirs(RESULT_IMAGE_DIR, exist_ok=True)
|
||||
|
||||
# ------------------- API Key 核心配置 (已加固) -------------------
|
||||
VALID_API_KEY = "123quant-speed"
|
||||
API_KEY_HEADER_NAME = "X-API-Key"
|
||||
|
||||
# 定义 Header 认证
|
||||
api_key_header = APIKeyHeader(name=API_KEY_HEADER_NAME, auto_error=False)
|
||||
|
||||
async def verify_api_key(api_key: Optional[str] = Depends(api_key_header)):
|
||||
"""
|
||||
强制验证 API Key
|
||||
"""
|
||||
# 1. 检查是否有 Key
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing API Key. Please provide it in the header."
|
||||
)
|
||||
# 2. 检查 Key 是否正确
|
||||
if api_key != VALID_API_KEY:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid API Key."
|
||||
)
|
||||
# 3. 验证通过
|
||||
return True
|
||||
|
||||
# ------------------- 生命周期管理 -------------------
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
print("="*40)
|
||||
print("✅ API Key 保护已激活")
|
||||
print(f"✅ 有效 Key: {VALID_API_KEY}")
|
||||
print("="*40)
|
||||
|
||||
print("正在加载 SAM3 模型到 GPU...")
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
if not torch.cuda.is_available():
|
||||
print("警告: 未检测到 GPU,将使用 CPU,速度会较慢。")
|
||||
|
||||
model = build_sam3_image_model()
|
||||
model = model.to(device)
|
||||
model.eval()
|
||||
|
||||
processor = Sam3Processor(model)
|
||||
|
||||
app.state.model = model
|
||||
app.state.processor = processor
|
||||
app.state.device = device
|
||||
|
||||
print(f"模型加载完成,设备: {device}")
|
||||
|
||||
yield
|
||||
|
||||
print("正在清理资源...")
|
||||
|
||||
# ------------------- FastAPI 初始化 -------------------
|
||||
app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
title="SAM3 Segmentation API",
|
||||
description="## 🔒 受 API Key 保护\n请点击右上角 **Authorize** 并输入: `123quant-speed`",
|
||||
)
|
||||
|
||||
# 手动添加 OpenAPI 安全配置,让 Docs 里的锁头生效
|
||||
app.openapi_schema = None
|
||||
def custom_openapi():
|
||||
if app.openapi_schema:
|
||||
return app.openapi_schema
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
openapi_schema = get_openapi(
|
||||
title=app.title,
|
||||
version=app.version,
|
||||
description=app.description,
|
||||
routes=app.routes,
|
||||
)
|
||||
# 定义安全方案
|
||||
openapi_schema["components"]["securitySchemes"] = {
|
||||
"APIKeyHeader": {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": API_KEY_HEADER_NAME,
|
||||
}
|
||||
}
|
||||
# 为所有路径应用安全要求
|
||||
for path in openapi_schema["paths"]:
|
||||
for method in openapi_schema["paths"][path]:
|
||||
openapi_schema["paths"][path][method]["security"] = [{"APIKeyHeader": []}]
|
||||
app.openapi_schema = openapi_schema
|
||||
return app.openapi_schema
|
||||
|
||||
app.openapi = custom_openapi
|
||||
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
|
||||
# ------------------- 辅助函数 -------------------
|
||||
def load_image_from_url(url: str) -> Image.Image:
|
||||
try:
|
||||
headers = {'User-Agent': 'Mozilla/5.0'}
|
||||
response = requests.get(url, headers=headers, stream=True, timeout=10)
|
||||
response.raise_for_status()
|
||||
image = Image.open(response.raw).convert("RGB")
|
||||
return image
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"无法下载图片: {str(e)}")
|
||||
|
||||
def generate_and_save_result(image: Image.Image, inference_state) -> str:
|
||||
filename = f"seg_{uuid.uuid4().hex}.jpg"
|
||||
save_path = os.path.join(RESULT_IMAGE_DIR, filename)
|
||||
plot_results(image, inference_state)
|
||||
plt.savefig(save_path, dpi=150, bbox_inches='tight')
|
||||
plt.close()
|
||||
return filename
|
||||
|
||||
# ------------------- API 接口 (强制依赖验证) -------------------
|
||||
@app.post("/segment", dependencies=[Depends(verify_api_key)])
|
||||
async def segment(
|
||||
request: Request,
|
||||
prompt: str = Form(...),
|
||||
file: Optional[UploadFile] = File(None),
|
||||
image_url: Optional[str] = Form(None)
|
||||
):
|
||||
if not file and not image_url:
|
||||
raise HTTPException(status_code=400, detail="必须提供 file (图片文件) 或 image_url (图片链接)")
|
||||
|
||||
try:
|
||||
if file:
|
||||
image = Image.open(file.file).convert("RGB")
|
||||
elif image_url:
|
||||
image = load_image_from_url(image_url)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"图片解析失败: {str(e)}")
|
||||
|
||||
processor = request.app.state.processor
|
||||
|
||||
try:
|
||||
inference_state = processor.set_image(image)
|
||||
output = processor.set_text_prompt(state=inference_state, prompt=prompt)
|
||||
masks, boxes, scores = output["masks"], output["boxes"], output["scores"]
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"模型推理错误: {str(e)}")
|
||||
|
||||
try:
|
||||
filename = generate_and_save_result(image, inference_state)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"绘图保存错误: {str(e)}")
|
||||
|
||||
file_url = request.url_for("static", path=f"results/{filename}")
|
||||
|
||||
return JSONResponse(content={
|
||||
"status": "success",
|
||||
"result_image_url": str(file_url),
|
||||
"detected_count": len(masks),
|
||||
"scores": scores.tolist() if torch.is_tensor(scores) else scores
|
||||
})
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
# 注意:如果你的文件名不是 fastAPI_nocom.py,请修改下面第一个参数
|
||||
uvicorn.run(
|
||||
"fastAPI_nocom:app",
|
||||
host="127.0.0.1",
|
||||
port=55600,
|
||||
proxy_headers=True,
|
||||
forwarded_allow_ips="*",
|
||||
reload=False # 生产环境建议关闭 reload,确保代码完全重载
|
||||
)
|
||||
1241
fastAPI_tarot.py
1241
fastAPI_tarot.py
File diff suppressed because it is too large
Load Diff
0
history.json
Normal file
0
history.json
Normal file
@@ -6,6 +6,8 @@ import numpy as np
|
||||
import json
|
||||
import torch
|
||||
import cv2
|
||||
import ast
|
||||
import re
|
||||
from PIL import Image
|
||||
from dashscope import MultiModalConversation
|
||||
|
||||
@@ -95,7 +97,36 @@ def create_highlighted_visualization(image: Image.Image, masks, output_path: str
|
||||
# Save
|
||||
Image.fromarray(result_np).save(output_path)
|
||||
|
||||
def analyze_demographics_with_qwen(image_path: str) -> dict:
|
||||
def extract_json_from_response(text: str) -> dict:
|
||||
"""
|
||||
Robustly extract JSON from text, handling:
|
||||
1. Markdown code blocks (```json ... ```)
|
||||
2. Single quotes (Python dict style) via ast.literal_eval
|
||||
"""
|
||||
try:
|
||||
# 1. Try to find JSON block
|
||||
json_match = re.search(r'```json\s*(.*?)\s*```', text, re.DOTALL)
|
||||
if json_match:
|
||||
clean_text = json_match.group(1).strip()
|
||||
else:
|
||||
# Try to find { ... } block if no markdown
|
||||
match = re.search(r'\{.*\}', text, re.DOTALL)
|
||||
if match:
|
||||
clean_text = match.group(0).strip()
|
||||
else:
|
||||
clean_text = text.strip()
|
||||
|
||||
# 2. Try standard JSON
|
||||
return json.loads(clean_text)
|
||||
except Exception as e1:
|
||||
# 3. Try ast.literal_eval for single quotes
|
||||
try:
|
||||
return ast.literal_eval(clean_text)
|
||||
except Exception as e2:
|
||||
# 4. Fail
|
||||
raise ValueError(f"Could not parse JSON: {e1} | {e2} | Content: {text[:100]}...")
|
||||
|
||||
def analyze_demographics_with_qwen(image_path: str, model_name: str = 'qwen-vl-max', prompt_template: str = None) -> dict:
|
||||
"""
|
||||
调用 Qwen-VL 模型分析人物的年龄和性别
|
||||
"""
|
||||
@@ -104,56 +135,65 @@ def analyze_demographics_with_qwen(image_path: str) -> dict:
|
||||
abs_path = os.path.abspath(image_path)
|
||||
file_url = f"file://{abs_path}"
|
||||
|
||||
# 默认 Prompt
|
||||
default_prompt = """请仔细观察这张图片中的人物头部/面部特写:
|
||||
1. 识别性别 (Gender):男性/女性
|
||||
2. 预估年龄 (Age):请给出一个合理的年龄范围,例如 "25-30岁"
|
||||
3. 简要描述:发型、发色、是否有眼镜等显著特征。
|
||||
|
||||
请以 JSON 格式返回,包含 'gender', 'age', 'description' 字段。
|
||||
不要包含 Markdown 标记。"""
|
||||
|
||||
final_prompt = prompt_template if prompt_template else default_prompt
|
||||
|
||||
# 构造 Prompt
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"image": file_url},
|
||||
{"text": """请仔细观察这张图片中的人物头部/面部特写:
|
||||
1. 识别性别 (Gender):男性/女性
|
||||
2. 预估年龄 (Age):请给出一个合理的年龄范围,例如 "25-30岁"
|
||||
3. 简要描述:发型、发色、是否有眼镜等显著特征。
|
||||
|
||||
请以 JSON 格式返回,包含 'gender', 'age', 'description' 字段。
|
||||
不要包含 Markdown 标记。"""}
|
||||
{"text": final_prompt}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
# 调用模型
|
||||
response = MultiModalConversation.call(model=QWEN_MODEL, messages=messages)
|
||||
response = MultiModalConversation.call(model=model_name, messages=messages)
|
||||
|
||||
if response.status_code == 200:
|
||||
content = response.output.choices[0].message.content[0]['text']
|
||||
# 清理 Markdown 代码块标记
|
||||
clean_content = content.replace("```json", "").replace("```", "").strip()
|
||||
try:
|
||||
result = json.loads(clean_content)
|
||||
result = extract_json_from_response(content)
|
||||
result["model_used"] = model_name
|
||||
return result
|
||||
except json.JSONDecodeError:
|
||||
return {"raw_analysis": clean_content}
|
||||
except Exception as e:
|
||||
print(f"JSON Parse Error in face analysis: {e}")
|
||||
return {"raw_analysis": content, "error": str(e), "model_used": model_name}
|
||||
else:
|
||||
return {"error": f"API Error: {response.code} - {response.message}"}
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"分析失败: {str(e)}"}
|
||||
|
||||
import asyncio
|
||||
|
||||
def process_face_segmentation_and_analysis(
|
||||
processor,
|
||||
image: Image.Image,
|
||||
prompt: str = "head",
|
||||
output_base_dir: str = "static/results"
|
||||
output_base_dir: str = "static/results",
|
||||
qwen_model: str = "qwen-vl-max",
|
||||
analysis_prompt: str = None
|
||||
) -> dict:
|
||||
"""
|
||||
核心处理逻辑:
|
||||
1. SAM3 分割 (默认提示词 "head" 以包含头发)
|
||||
2. 裁剪图片
|
||||
3. Qwen-VL 识别性别年龄
|
||||
3. Qwen-VL 识别性别年龄 (并发)
|
||||
4. 返回结果
|
||||
"""
|
||||
|
||||
# 1. SAM3 推理
|
||||
# 1. SAM3 推理 (同步,因为涉及 GPU 操作)
|
||||
inference_state = processor.set_image(image)
|
||||
output = processor.set_text_prompt(state=inference_state, prompt=prompt)
|
||||
masks, boxes, scores = output["masks"], output["boxes"], output["scores"]
|
||||
@@ -172,7 +212,7 @@ def process_face_segmentation_and_analysis(
|
||||
output_dir = os.path.join(output_base_dir, request_id)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# --- 新增:生成背景变暗的整体可视化图 ---
|
||||
# --- 生成可视化图 ---
|
||||
vis_filename = f"seg_{uuid.uuid4().hex}.jpg"
|
||||
vis_path = os.path.join(output_dir, vis_filename)
|
||||
try:
|
||||
@@ -181,38 +221,238 @@ def process_face_segmentation_and_analysis(
|
||||
except Exception as e:
|
||||
print(f"可视化生成失败: {e}")
|
||||
full_vis_relative_path = None
|
||||
# -------------------------------------
|
||||
# ------------------
|
||||
|
||||
results = []
|
||||
|
||||
# 转换 boxes 为 numpy
|
||||
# 转换 boxes 和 scores
|
||||
if isinstance(boxes, torch.Tensor):
|
||||
boxes_np = boxes.cpu().numpy()
|
||||
else:
|
||||
boxes_np = boxes
|
||||
|
||||
# 转换 scores 为 list
|
||||
if isinstance(scores, torch.Tensor):
|
||||
scores_list = scores.tolist()
|
||||
else:
|
||||
scores_list = scores if isinstance(scores, list) else [float(scores)]
|
||||
|
||||
for i, box in enumerate(boxes_np):
|
||||
# 2. 裁剪 (带一点 padding 以保留完整发型)
|
||||
# 2. 裁剪 (带一点 padding 以保留完整发型)
|
||||
cropped_img = crop_head_with_padding(image, box, padding_ratio=0.1)
|
||||
# 准备异步任务
|
||||
async def run_analysis_tasks():
|
||||
loop = asyncio.get_event_loop()
|
||||
tasks = []
|
||||
temp_results = [] # 存储 (index, filename, score) 以便后续排序组合
|
||||
|
||||
for i, box in enumerate(boxes_np):
|
||||
# 2. 裁剪 (同步)
|
||||
cropped_img = crop_head_with_padding(image, box, padding_ratio=0.1)
|
||||
filename = f"face_{i}.jpg"
|
||||
save_path = os.path.join(output_dir, filename)
|
||||
cropped_img.save(save_path)
|
||||
|
||||
# 3. 准备识别任务
|
||||
task = loop.run_in_executor(
|
||||
None,
|
||||
analyze_demographics_with_qwen,
|
||||
save_path,
|
||||
qwen_model,
|
||||
analysis_prompt
|
||||
)
|
||||
tasks.append(task)
|
||||
temp_results.append({
|
||||
"filename": filename,
|
||||
"relative_path": f"results/{request_id}/{filename}",
|
||||
"score": float(scores_list[i]) if i < len(scores_list) else 0.0
|
||||
})
|
||||
|
||||
# 等待所有任务完成
|
||||
if tasks:
|
||||
analysis_results = await asyncio.gather(*tasks)
|
||||
else:
|
||||
analysis_results = []
|
||||
|
||||
# 组合结果
|
||||
final_results = []
|
||||
for i, item in enumerate(temp_results):
|
||||
item["analysis"] = analysis_results[i]
|
||||
final_results.append(item)
|
||||
|
||||
return final_results
|
||||
|
||||
# 运行异步任务
|
||||
# 注意:由于本函数被 FastAPI (异步环境) 中的同步或异步函数调用,
|
||||
# 如果上层是 async def,我们可以直接 await。
|
||||
# 但由于这个函数定义没有 async,且之前的调用是同步的,
|
||||
# 为了兼容性,我们需要检查当前是否在事件循环中。
|
||||
|
||||
# 然而,查看 fastAPI_tarot.py,这个函数是在 async def segment_face 中被调用的。
|
||||
# 但它是作为普通函数被导入和调用的。
|
||||
# 为了不破坏现有签名,我们可以使用 asyncio.run() 或者在新循环中运行,
|
||||
# 但这在已经运行的 loop 中是不允许的。
|
||||
|
||||
# 最佳方案:修改本函数为 async,并在 fastAPI_tarot.py 中 await 它。
|
||||
# 但这需要修改 fastAPI_tarot.py 的调用处。
|
||||
|
||||
# 既然我们已经修改了 fastAPI_tarot.py,我们也可以顺便修改这里的签名。
|
||||
# 但为了稳妥,我们可以用一种 hack:
|
||||
# 如果在一个正在运行的 loop 中调用,我们必须返回 awaitable 或者使用 loop.run_until_complete (会报错)
|
||||
|
||||
# 让我们先把这个函数改成 async,然后去修改 fastAPI_tarot.py 的调用。
|
||||
# 这是最正确的做法。
|
||||
pass # 占位,实际代码在下面
|
||||
|
||||
async def process_face_segmentation_and_analysis_async(
|
||||
processor,
|
||||
image: Image.Image,
|
||||
prompt: str = "head",
|
||||
output_base_dir: str = "static/results",
|
||||
qwen_model: str = "qwen-vl-max",
|
||||
analysis_prompt: str = None
|
||||
) -> dict:
|
||||
# ... (同上逻辑,只是是 async)
|
||||
|
||||
# 1. SAM3 推理
|
||||
inference_state = processor.set_image(image)
|
||||
output = processor.set_text_prompt(state=inference_state, prompt=prompt)
|
||||
masks, boxes, scores = output["masks"], output["boxes"], output["scores"]
|
||||
|
||||
detected_count = len(masks)
|
||||
if detected_count == 0:
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "未检测到目标",
|
||||
"detected_count": 0,
|
||||
"results": []
|
||||
}
|
||||
|
||||
# 保存裁剪图
|
||||
request_id = f"{int(time.time())}_{uuid.uuid4().hex[:8]}"
|
||||
output_dir = os.path.join(output_base_dir, request_id)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
vis_filename = f"seg_{uuid.uuid4().hex}.jpg"
|
||||
vis_path = os.path.join(output_dir, vis_filename)
|
||||
try:
|
||||
create_highlighted_visualization(image, masks, vis_path)
|
||||
full_vis_relative_path = f"results/{request_id}/{vis_filename}"
|
||||
except Exception as e:
|
||||
print(f"可视化生成失败: {e}")
|
||||
full_vis_relative_path = None
|
||||
|
||||
if isinstance(boxes, torch.Tensor):
|
||||
boxes_np = boxes.cpu().numpy()
|
||||
else:
|
||||
boxes_np = boxes
|
||||
|
||||
if isinstance(scores, torch.Tensor):
|
||||
scores_list = scores.tolist()
|
||||
else:
|
||||
scores_list = scores if isinstance(scores, list) else [float(scores)]
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
tasks = []
|
||||
results = []
|
||||
|
||||
for i, box in enumerate(boxes_np):
|
||||
cropped_img = crop_head_with_padding(image, box, padding_ratio=0.1)
|
||||
filename = f"face_{i}.jpg"
|
||||
save_path = os.path.join(output_dir, filename)
|
||||
cropped_img.save(save_path)
|
||||
|
||||
# 3. 识别
|
||||
analysis = analyze_demographics_with_qwen(save_path)
|
||||
task = loop.run_in_executor(
|
||||
None,
|
||||
analyze_demographics_with_qwen,
|
||||
save_path,
|
||||
qwen_model,
|
||||
analysis_prompt
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
results.append({
|
||||
"filename": filename,
|
||||
"relative_path": f"results/{request_id}/{filename}",
|
||||
"score": float(scores_list[i]) if i < len(scores_list) else 0.0
|
||||
})
|
||||
|
||||
if tasks:
|
||||
analysis_results = await asyncio.gather(*tasks)
|
||||
else:
|
||||
analysis_results = []
|
||||
|
||||
for i, item in enumerate(results):
|
||||
item["analysis"] = analysis_results[i]
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"成功检测并分析 {detected_count} 个人脸",
|
||||
"detected_count": detected_count,
|
||||
"request_id": request_id,
|
||||
"full_visualization": full_vis_relative_path,
|
||||
"scores": scores_list,
|
||||
"results": results
|
||||
}
|
||||
|
||||
# 保留旧的同步接口以兼容其他潜在调用者,但内部实现可能会有问题如果它在 loop 中运行
|
||||
# 既然我们主要关注 fastAPI_tarot.py,我们可以直接替换 process_face_segmentation_and_analysis
|
||||
# 或者让它只是一个 wrapper
|
||||
def process_face_segmentation_and_analysis(
|
||||
processor,
|
||||
image: Image.Image,
|
||||
prompt: str = "head",
|
||||
output_base_dir: str = "static/results",
|
||||
qwen_model: str = "qwen-vl-max",
|
||||
analysis_prompt: str = None
|
||||
) -> dict:
|
||||
"""
|
||||
同步版本 (保留以兼容)
|
||||
注意:如果在 async loop 中调用此函数,且此函数内部没有异步操作,则会阻塞 loop。
|
||||
如果需要异步并发,请使用 process_face_segmentation_and_analysis_async
|
||||
"""
|
||||
# 这里我们简单地复用逻辑,但去除异步部分,退化为串行
|
||||
|
||||
# 1. SAM3 推理
|
||||
inference_state = processor.set_image(image)
|
||||
output = processor.set_text_prompt(state=inference_state, prompt=prompt)
|
||||
masks, boxes, scores = output["masks"], output["boxes"], output["scores"]
|
||||
|
||||
detected_count = len(masks)
|
||||
if detected_count == 0:
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "未检测到目标",
|
||||
"detected_count": 0,
|
||||
"results": []
|
||||
}
|
||||
|
||||
request_id = f"{int(time.time())}_{uuid.uuid4().hex[:8]}"
|
||||
output_dir = os.path.join(output_base_dir, request_id)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
vis_filename = f"seg_{uuid.uuid4().hex}.jpg"
|
||||
vis_path = os.path.join(output_dir, vis_filename)
|
||||
try:
|
||||
create_highlighted_visualization(image, masks, vis_path)
|
||||
full_vis_relative_path = f"results/{request_id}/{vis_filename}"
|
||||
except Exception as e:
|
||||
print(f"可视化生成失败: {e}")
|
||||
full_vis_relative_path = None
|
||||
|
||||
if isinstance(boxes, torch.Tensor):
|
||||
boxes_np = boxes.cpu().numpy()
|
||||
else:
|
||||
boxes_np = boxes
|
||||
|
||||
if isinstance(scores, torch.Tensor):
|
||||
scores_list = scores.tolist()
|
||||
else:
|
||||
scores_list = scores if isinstance(scores, list) else [float(scores)]
|
||||
|
||||
results = []
|
||||
for i, box in enumerate(boxes_np):
|
||||
cropped_img = crop_head_with_padding(image, box, padding_ratio=0.1)
|
||||
filename = f"face_{i}.jpg"
|
||||
save_path = os.path.join(output_dir, filename)
|
||||
cropped_img.save(save_path)
|
||||
|
||||
# 同步调用
|
||||
analysis = analyze_demographics_with_qwen(save_path, model_name=qwen_model, prompt_template=analysis_prompt)
|
||||
|
||||
# 构造返回结果
|
||||
# 注意:URL 生成需要依赖外部的 request context,这里只返回相对路径或文件名
|
||||
# 由调用方组装完整 URL
|
||||
results.append({
|
||||
"filename": filename,
|
||||
"relative_path": f"results/{request_id}/{filename}",
|
||||
@@ -225,7 +465,8 @@ def process_face_segmentation_and_analysis(
|
||||
"message": f"成功检测并分析 {detected_count} 个人脸",
|
||||
"detected_count": detected_count,
|
||||
"request_id": request_id,
|
||||
"full_visualization": full_vis_relative_path, # 返回相对路径
|
||||
"scores": scores_list, # 返回全部分数
|
||||
"full_visualization": full_vis_relative_path,
|
||||
"scores": scores_list,
|
||||
"results": results
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
164
run_monitor.sh
Executable file
164
run_monitor.sh
Executable file
@@ -0,0 +1,164 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==============================================================================
|
||||
# SAM3 项目启动与监控脚本
|
||||
# 功能:启动 Python FastAPI 服务,并持续监控健康状态
|
||||
# 作者:Trae AI
|
||||
# 日期:2026-02-17
|
||||
# ==============================================================================
|
||||
|
||||
# 配置部分
|
||||
PROJECT_DIR="/home/quant/data/dev/sam3" # 项目根目录
|
||||
SCRIPT_NAME="fastAPI_tarot.py" # Python 启动脚本
|
||||
LOG_FILE="${PROJECT_DIR}/log/monitor.log" # 监控日志文件
|
||||
APP_LOG_FILE="${PROJECT_DIR}/log/app.log" # 应用输出日志文件
|
||||
PORT=55600 # 服务端口
|
||||
CHECK_INTERVAL=60 # 检查间隔(秒)
|
||||
MAX_FAILURES=3 # 最大连续失败次数,超过则重启
|
||||
STARTUP_TIMEOUT=300 # 启动超时时间(秒),等待模型加载
|
||||
PYTHON_CMD="python" # Python 命令,根据环境可能是 python3
|
||||
|
||||
# 切换到项目目录
|
||||
cd "$PROJECT_DIR" || exit 1
|
||||
|
||||
# 初始化变量
|
||||
APP_PID=0
|
||||
FAIL_COUNT=0
|
||||
|
||||
# ==============================================================================
|
||||
# 函数:记录日志 (log_message)
|
||||
# 功能:将带有时间戳的信息写入日志文件并输出到控制台
|
||||
# 参数:$1 - 日志内容
|
||||
# ==============================================================================
|
||||
log_message() {
|
||||
local timestamp=$(date "+%Y-%m-%d %H:%M:%S")
|
||||
echo "[$timestamp] $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# 函数:启动应用 (start_app)
|
||||
# 功能:启动 FastAPI 服务,并记录 PID
|
||||
# ==============================================================================
|
||||
start_app() {
|
||||
log_message "正在启动项目: $SCRIPT_NAME ..."
|
||||
log_message "应用日志将输出到: $APP_LOG_FILE"
|
||||
|
||||
# 后台启动 Python 脚本,将 stdout 和 stderr 重定向到日志
|
||||
# 使用 -u 参数启用无缓冲输出,确保日志实时更新
|
||||
nohup $PYTHON_CMD -u "$SCRIPT_NAME" > "$APP_LOG_FILE" 2>&1 &
|
||||
|
||||
APP_PID=$!
|
||||
log_message "项目已启动,PID: $APP_PID"
|
||||
|
||||
log_message "正在等待服务初始化 (最多等待 ${STARTUP_TIMEOUT} 秒)..."
|
||||
|
||||
# 循环检查服务是否就绪
|
||||
local elapsed=0
|
||||
while [ $elapsed -lt $STARTUP_TIMEOUT ]; do
|
||||
# 检查进程是否还活着
|
||||
if ! kill -0 $APP_PID 2>/dev/null; then
|
||||
log_message "错误: 进程在启动过程中退出。请检查应用日志。"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 检查端口响应
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT/docs")
|
||||
if [ "$HTTP_CODE" == "200" ]; then
|
||||
log_message "服务启动成功!"
|
||||
return 0
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
elapsed=$((elapsed + 5))
|
||||
|
||||
# 每30秒打印一次等待日志
|
||||
if [ $((elapsed % 30)) -eq 0 ]; then
|
||||
log_message "仍在等待服务启动... (已耗时 ${elapsed} 秒)"
|
||||
fi
|
||||
done
|
||||
|
||||
log_message "错误: 服务启动超时 (${STARTUP_TIMEOUT} 秒)。正在终止进程..."
|
||||
kill -9 $APP_PID 2>/dev/null
|
||||
return 1
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# 函数:停止应用 (stop_app)
|
||||
# 功能:通过 PID 停止应用,如果失败则强制杀死
|
||||
# ==============================================================================
|
||||
stop_app() {
|
||||
if [ $APP_PID -gt 0 ]; then
|
||||
log_message "正在停止项目 (PID: $APP_PID)..."
|
||||
kill $APP_PID 2>/dev/null
|
||||
|
||||
# 等待进程结束
|
||||
for i in {1..5}; do
|
||||
if ! kill -0 $APP_PID 2>/dev/null; then
|
||||
log_message "项目已停止"
|
||||
return
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# 如果还在运行,强制杀死
|
||||
log_message "项目未响应,正在强制终止..."
|
||||
kill -9 $APP_PID 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# 函数:检查健康状态 (check_health)
|
||||
# 功能:检查进程是否存在以及端口是否响应
|
||||
# 返回:0 (正常) / 1 (异常)
|
||||
# ==============================================================================
|
||||
check_health() {
|
||||
# 1. 检查进程是否存在
|
||||
if ! kill -0 $APP_PID 2>/dev/null; then
|
||||
log_message "警告: 进程 $APP_PID 不存在"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 2. 检查端口响应 (请求 /docs 接口)
|
||||
# 使用 curl 获取 HTTP 状态码
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT/docs")
|
||||
|
||||
if [ "$HTTP_CODE" == "200" ]; then
|
||||
return 0
|
||||
else
|
||||
log_message "警告: 健康检查失败,HTTP 状态码: $HTTP_CODE"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# 主循环
|
||||
# ==============================================================================
|
||||
|
||||
# 初始启动
|
||||
start_app
|
||||
|
||||
while true; do
|
||||
if check_health; then
|
||||
# 健康检查通过
|
||||
FAIL_COUNT=0
|
||||
# log_message "健康检查通过" # 可选:为了减少日志量,可以注释掉这行
|
||||
else
|
||||
# 健康检查失败
|
||||
((FAIL_COUNT++))
|
||||
log_message "健康检查失败 ($FAIL_COUNT/$MAX_FAILURES)"
|
||||
|
||||
if [ $FAIL_COUNT -ge $MAX_FAILURES ]; then
|
||||
log_message "错误: 连续检测失败次数过多,准备重启项目..."
|
||||
stop_app
|
||||
start_app
|
||||
FAIL_COUNT=0
|
||||
elif ! kill -0 $APP_PID 2>/dev/null; then
|
||||
# 如果进程直接没了,立即重启
|
||||
log_message "错误: 进程意外退出,立即重启..."
|
||||
start_app
|
||||
FAIL_COUNT=0
|
||||
fi
|
||||
fi
|
||||
|
||||
sleep $CHECK_INTERVAL
|
||||
done
|
||||
2050
static/admin.html
Normal file
2050
static/admin.html
Normal file
File diff suppressed because it is too large
Load Diff
579
static/admin.html.bak
Normal file
579
static/admin.html.bak
Normal file
@@ -0,0 +1,579 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SAM3 项目管理后台</title>
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
[v-cloak] { display: none; }
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.5s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-100 min-h-screen text-gray-800">
|
||||
<div id="app" v-cloak>
|
||||
<!-- 登录页 -->
|
||||
<div v-if="!isLoggedIn" class="flex items-center justify-center min-h-screen">
|
||||
<div class="bg-white p-8 rounded-lg shadow-lg w-96">
|
||||
<h1 class="text-2xl font-bold mb-6 text-center text-blue-600">SAM3 管理后台</h1>
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 text-sm font-bold mb-2">管理员密码</label>
|
||||
<input v-model="password" type="password" @keyup.enter="login" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="请输入密码">
|
||||
</div>
|
||||
<button @click="login" class="w-full bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline transition duration-300">
|
||||
登录
|
||||
</button>
|
||||
<p v-if="loginError" class="text-red-500 text-xs italic mt-2">{{ loginError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主界面 -->
|
||||
<div v-else class="flex h-screen overflow-hidden">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="w-64 bg-slate-800 text-white flex flex-col">
|
||||
<div class="p-6 border-b border-slate-700">
|
||||
<h2 class="text-xl font-bold flex items-center gap-2">
|
||||
<i class="fas fa-layer-group"></i> SAM3 Admin
|
||||
</h2>
|
||||
</div>
|
||||
<nav class="flex-1 p-4 space-y-2">
|
||||
<a href="#" @click.prevent="currentTab = 'dashboard'" :class="{'bg-blue-600': currentTab === 'dashboard', 'hover:bg-slate-700': currentTab !== 'dashboard'}" class="block py-2.5 px-4 rounded transition duration-200">
|
||||
<i class="fas fa-chart-line w-6"></i> 识别记录
|
||||
</a>
|
||||
<a href="#" @click.prevent="currentTab = 'files'" :class="{'bg-blue-600': currentTab === 'files', 'hover:bg-slate-700': currentTab !== 'files'}" class="block py-2.5 px-4 rounded transition duration-200">
|
||||
<i class="fas fa-folder-open w-6"></i> 文件管理
|
||||
</a>
|
||||
<a href="#" @click.prevent="currentTab = 'prompts'" :class="{'bg-blue-600': currentTab === 'prompts', 'hover:bg-slate-700': currentTab !== 'prompts'}" class="block py-2.5 px-4 rounded transition duration-200">
|
||||
<i class="fas fa-comment-dots w-6"></i> 提示词管理
|
||||
</a>
|
||||
<a href="#" @click.prevent="currentTab = 'settings'" :class="{'bg-blue-600': currentTab === 'settings', 'hover:bg-slate-700': currentTab !== 'settings'}" class="block py-2.5 px-4 rounded transition duration-200">
|
||||
<i class="fas fa-cogs w-6"></i> 系统设置
|
||||
</a>
|
||||
</nav>
|
||||
<div class="p-4 border-t border-slate-700">
|
||||
<button @click="logout" class="w-full bg-red-600 hover:bg-red-700 text-white py-2 px-4 rounded transition duration-200">
|
||||
<i class="fas fa-sign-out-alt"></i> 退出登录
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<main class="flex-1 overflow-y-auto bg-gray-50 p-8">
|
||||
<!-- 识别记录 Dashboard -->
|
||||
<div v-if="currentTab === 'dashboard'">
|
||||
<h2 class="text-2xl font-bold mb-6 text-gray-800 border-b pb-2 flex items-center justify-between">
|
||||
<span>最近识别记录</span>
|
||||
<button @click="fetchHistory" class="bg-blue-500 hover:bg-blue-600 text-white py-1 px-3 rounded text-sm transition shadow-sm">
|
||||
<i class="fas fa-sync-alt mr-1"></i> 刷新
|
||||
</button>
|
||||
</h2>
|
||||
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden border border-gray-100">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full leading-normal">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-5 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider w-32">时间</th>
|
||||
<th class="px-5 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider w-24">类型</th>
|
||||
<th class="px-5 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Prompt / 详情</th>
|
||||
<th class="px-5 py-3 border-b border-gray-200 bg-gray-50 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider w-24">耗时</th>
|
||||
<th class="px-5 py-3 border-b border-gray-200 bg-gray-50 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider w-24">状态</th>
|
||||
<th class="px-5 py-3 border-b border-gray-200 bg-gray-50 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider w-20">查看</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(record, index) in history" :key="index" class="hover:bg-gray-50 transition duration-150">
|
||||
<td class="px-5 py-4 border-b border-gray-100 bg-white text-sm text-gray-600 whitespace-nowrap">
|
||||
<div class="font-medium">{{ formatDate(record.timestamp).split(' ')[0] }}</div>
|
||||
<div class="text-xs text-gray-400">{{ formatDate(record.timestamp).split(' ')[1] }}</div>
|
||||
</td>
|
||||
<td class="px-5 py-4 border-b border-gray-100 bg-white text-sm">
|
||||
<span :class="getTypeBadgeClass(record.type)" class="px-2 py-1 text-xs font-semibold rounded-md shadow-sm">
|
||||
{{ record.type }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-5 py-4 border-b border-gray-100 bg-white text-sm">
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- Prompt -->
|
||||
<div v-if="record.prompt" class="font-medium text-gray-800 break-words flex items-center gap-2">
|
||||
<i class="fas fa-keyboard text-gray-300 text-xs"></i>
|
||||
{{ record.prompt }}
|
||||
</div>
|
||||
|
||||
<!-- Translated Prompt -->
|
||||
<div v-if="record.final_prompt && record.final_prompt !== record.prompt" class="text-xs text-gray-500 flex items-center gap-2">
|
||||
<i class="fas fa-language text-blue-300 text-xs"></i>
|
||||
<span class="italic bg-gray-50 px-1 rounded">{{ record.final_prompt }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Details -->
|
||||
<div class="text-xs text-gray-400 mt-1 flex items-center gap-2">
|
||||
<i class="fas fa-info-circle text-gray-300"></i>
|
||||
{{ record.details }}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-5 py-4 border-b border-gray-100 bg-white text-sm text-center">
|
||||
<div v-if="record.duration" :class="getDurationClass(record.duration)" class="font-mono text-xs inline-block px-2 py-0.5 rounded">
|
||||
{{ record.duration.toFixed(2) }}s
|
||||
</div>
|
||||
<div v-else class="text-gray-300 text-xs">-</div>
|
||||
</td>
|
||||
<td class="px-5 py-4 border-b border-gray-100 bg-white text-sm text-center">
|
||||
<span :class="record.status === 'success' ? 'text-green-700 bg-green-100 ring-1 ring-green-200' : (record.status === 'partial_success' ? 'text-yellow-700 bg-yellow-100 ring-1 ring-yellow-200' : 'text-red-700 bg-red-100 ring-1 ring-red-200')" class="px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full">
|
||||
<i :class="record.status === 'success' ? 'fas fa-check-circle' : (record.status === 'partial_success' ? 'fas fa-exclamation-circle' : 'fas fa-times-circle')" class="mr-1 mt-0.5"></i>
|
||||
{{ record.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-5 py-4 border-b border-gray-100 bg-white text-sm text-center">
|
||||
<button v-if="record.result_path" @click="viewResult(record.result_path)" class="text-blue-500 hover:text-blue-700 transition transform hover:scale-110" title="查看结果">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</button>
|
||||
<span v-else class="text-gray-300 cursor-not-allowed">
|
||||
<i class="fas fa-eye-slash"></i>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="history.length === 0">
|
||||
<td colspan="6" class="px-5 py-10 border-b border-gray-200 bg-white text-sm text-center text-gray-400">
|
||||
<div class="flex flex-col items-center">
|
||||
<i class="fas fa-inbox text-4xl mb-3 text-gray-200"></i>
|
||||
暂无记录
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件管理 Files -->
|
||||
<div v-if="currentTab === 'files'">
|
||||
<h2 class="text-2xl font-bold mb-6 text-gray-800 border-b pb-2">文件资源管理 (static/results)</h2>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-4 mb-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-sm text-gray-600">
|
||||
<span class="font-bold">当前路径:</span> /static/results/{{ currentPath }}
|
||||
<button v-if="currentPath" @click="navigateUp" class="ml-2 text-blue-500 hover:underline text-xs">
|
||||
<i class="fas fa-level-up-alt"></i> 返回上一级
|
||||
</button>
|
||||
</div>
|
||||
<button @click="fetchFiles" class="text-blue-500 hover:text-blue-700">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
<div v-for="file in files" :key="file.name" class="border rounded-lg p-2 hover:shadow-md transition cursor-pointer relative group">
|
||||
<!-- Folder -->
|
||||
<div v-if="file.is_dir" @click="enterDir(file.name)" class="flex flex-col items-center justify-center h-32">
|
||||
<i class="fas fa-folder text-yellow-400 text-4xl mb-2"></i>
|
||||
<span class="text-xs text-center break-all px-1">{{ file.name }}</span>
|
||||
<span class="text-xs text-gray-400">{{ file.count }} 项</span>
|
||||
</div>
|
||||
<!-- Image File -->
|
||||
<div v-else-if="isImage(file.name)" @click="previewImage(file.url)" class="flex flex-col items-center justify-center h-32">
|
||||
<img :src="file.url" class="h-20 w-auto object-contain mb-2 rounded" loading="lazy">
|
||||
<span class="text-xs text-center break-all px-1 truncate w-full">{{ file.name }}</span>
|
||||
</div>
|
||||
<!-- Other File -->
|
||||
<div v-else class="flex flex-col items-center justify-center h-32">
|
||||
<i class="fas fa-file text-gray-400 text-4xl mb-2"></i>
|
||||
<span class="text-xs text-center break-all px-1">{{ file.name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Delete Button (Hover) -->
|
||||
<button @click.stop="deleteFile(file.name)" class="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition hover:bg-red-700" title="删除">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="files.length === 0" class="text-center py-10 text-gray-500">
|
||||
此目录下没有文件
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示词管理 Prompts -->
|
||||
<div v-if="currentTab === 'prompts'">
|
||||
<h2 class="text-2xl font-bold mb-6 text-gray-800 border-b pb-2">提示词管理</h2>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<div v-for="(content, key) in prompts" :key="key" class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-bold text-gray-700 flex items-center gap-2">
|
||||
<span class="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded border border-blue-400 font-mono">{{ key }}</span>
|
||||
<span class="text-sm font-normal text-gray-500">{{ getPromptDescription(key) }}</span>
|
||||
</h3>
|
||||
<button @click="savePrompt(key)" class="bg-green-500 hover:bg-green-600 text-white font-bold py-1 px-3 rounded text-sm transition">
|
||||
<i class="fas fa-save mr-1"></i> 保存
|
||||
</button>
|
||||
</div>
|
||||
<textarea v-model="prompts[key]" rows="6" class="w-full p-3 border rounded font-mono text-sm bg-gray-50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 transition"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统设置 Settings -->
|
||||
<div v-if="currentTab === 'settings'">
|
||||
<h2 class="text-2xl font-bold mb-6 text-gray-800 border-b pb-2">系统设置</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h3 class="text-lg font-bold mb-4 text-gray-700">自动清理配置</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center border-b pb-2">
|
||||
<span class="text-gray-600">启用自动清理</span>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" v-model="cleanupConfig.enabled" class="sr-only peer">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="border-b pb-2">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<span class="text-gray-600">文件保留时长 (秒)</span>
|
||||
<span class="text-xs text-gray-500">{{ (cleanupConfig.lifetime / 3600).toFixed(1) }} 小时</span>
|
||||
</div>
|
||||
<input type="number" v-model.number="cleanupConfig.lifetime" class="w-full border rounded px-2 py-1 text-sm">
|
||||
</div>
|
||||
|
||||
<div class="border-b pb-2">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<span class="text-gray-600">检查间隔 (秒)</span>
|
||||
<span class="text-xs text-gray-500">{{ (cleanupConfig.interval / 60).toFixed(1) }} 分钟</span>
|
||||
</div>
|
||||
<input type="number" v-model.number="cleanupConfig.interval" class="w-full border rounded px-2 py-1 text-sm">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button @click="saveCleanupConfig" class="flex-1 bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded transition text-sm">
|
||||
<i class="fas fa-save mr-2"></i> 保存配置
|
||||
</button>
|
||||
<button @click="triggerCleanup" :disabled="cleaning" class="flex-1 bg-yellow-500 hover:bg-yellow-600 text-white font-bold py-2 px-4 rounded transition text-sm">
|
||||
<i class="fas fa-broom mr-2"></i> {{ cleaning ? '清理中...' : '立即清理' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h3 class="text-lg font-bold mb-4 text-gray-700">系统信息</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between border-b pb-2">
|
||||
<span class="text-gray-600">模型</span>
|
||||
<span class="font-mono">SAM3</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center border-b pb-2">
|
||||
<span class="text-gray-600">多模态模型</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<select v-model="currentModel" @change="updateModel" class="border rounded px-2 py-1 text-sm font-mono bg-white">
|
||||
<option v-for="model in availableModels" :key="model" :value="model">
|
||||
{{ model }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between border-b pb-2">
|
||||
<span class="text-gray-600">设备</span>
|
||||
<span class="font-mono">{{ deviceInfo }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 图片预览模态框 -->
|
||||
<div v-if="previewUrl" class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-90" @click="previewUrl = null">
|
||||
<div class="relative max-w-4xl max-h-screen p-4">
|
||||
<img :src="previewUrl" class="max-h-[90vh] max-w-full rounded shadow-lg" @click.stop>
|
||||
<button class="absolute top-0 right-0 m-4 text-white text-3xl font-bold hover:text-gray-300" @click="previewUrl = null">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp, ref, onMounted, computed } = Vue;
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const isLoggedIn = ref(false);
|
||||
const password = ref('');
|
||||
const loginError = ref('');
|
||||
const currentTab = ref('dashboard');
|
||||
const history = ref([]);
|
||||
const files = ref([]);
|
||||
const currentPath = ref('');
|
||||
const previewUrl = ref(null);
|
||||
const cleaning = ref(false);
|
||||
const deviceInfo = ref('Loading...');
|
||||
const currentModel = ref('');
|
||||
const availableModels = ref([]);
|
||||
const cleanupConfig = ref({
|
||||
enabled: true,
|
||||
lifetime: 3600,
|
||||
interval: 600
|
||||
});
|
||||
const prompts = ref({});
|
||||
|
||||
// 检查登录状态
|
||||
const checkLogin = () => {
|
||||
const token = localStorage.getItem('admin_token');
|
||||
if (token) {
|
||||
isLoggedIn.value = true;
|
||||
fetchHistory();
|
||||
fetchSystemInfo();
|
||||
}
|
||||
};
|
||||
|
||||
const login = async () => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('password', password.value);
|
||||
const res = await axios.post('/admin/login', formData);
|
||||
if (res.data.status === 'success') {
|
||||
localStorage.setItem('admin_token', 'logged_in'); // 简单标记,实际由Cookie控制
|
||||
isLoggedIn.value = true;
|
||||
loginError.value = '';
|
||||
fetchHistory();
|
||||
fetchSystemInfo();
|
||||
fetchPrompts();
|
||||
}
|
||||
} catch (e) {
|
||||
loginError.value = '密码错误';
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('admin_token');
|
||||
document.cookie = "admin_session=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
isLoggedIn.value = false;
|
||||
password.value = '';
|
||||
};
|
||||
|
||||
const fetchHistory = async () => {
|
||||
try {
|
||||
const res = await axios.get('/admin/api/history');
|
||||
history.value = res.data.reverse(); // 最新在前
|
||||
} catch (e) {
|
||||
if (e.response && e.response.status === 401) logout();
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFiles = async () => {
|
||||
try {
|
||||
const res = await axios.get(`/admin/api/files?path=${currentPath.value}`);
|
||||
files.value = res.data;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSystemInfo = async () => {
|
||||
try {
|
||||
const res = await axios.get('/admin/api/config');
|
||||
deviceInfo.value = res.data.device;
|
||||
currentModel.value = res.data.current_qwen_model;
|
||||
availableModels.value = res.data.available_qwen_models;
|
||||
if (res.data.cleanup_config) {
|
||||
cleanupConfig.value = res.data.cleanup_config;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const saveCleanupConfig = async () => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('enabled', cleanupConfig.value.enabled);
|
||||
formData.append('lifetime', cleanupConfig.value.lifetime);
|
||||
formData.append('interval', cleanupConfig.value.interval);
|
||||
|
||||
const res = await axios.post('/admin/api/config/cleanup', formData);
|
||||
alert(res.data.message);
|
||||
} catch (e) {
|
||||
alert('保存配置失败: ' + (e.response?.data?.detail || e.message));
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPrompts = async () => {
|
||||
try {
|
||||
const res = await axios.get('/admin/api/prompts');
|
||||
prompts.value = res.data;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const savePrompt = async (key) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('key', key);
|
||||
formData.append('content', prompts.value[key]);
|
||||
const res = await axios.post('/admin/api/prompts', formData);
|
||||
alert(res.data.message);
|
||||
} catch (e) {
|
||||
alert('保存失败: ' + (e.response?.data?.detail || e.message));
|
||||
}
|
||||
};
|
||||
|
||||
const getPromptDescription = (key) => {
|
||||
const map = {
|
||||
'translate': 'Prompt 翻译 (中文 -> 英文)',
|
||||
'tarot_card_dual': '塔罗牌识别 (正/逆位对比模式)',
|
||||
'tarot_card_single': '塔罗牌识别 (单图模式)',
|
||||
'tarot_spread': '塔罗牌阵识别',
|
||||
'face_analysis': '人脸/属性分析 (Qwen-VL)'
|
||||
};
|
||||
return map[key] || '';
|
||||
};
|
||||
|
||||
const updateModel = async () => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('model', currentModel.value);
|
||||
const res = await axios.post('/admin/api/config/model', formData);
|
||||
alert(res.data.message);
|
||||
} catch (e) {
|
||||
alert('更新模型失败');
|
||||
console.error(e);
|
||||
// Revert on failure
|
||||
fetchSystemInfo();
|
||||
}
|
||||
};
|
||||
|
||||
const enterDir = (dirName) => {
|
||||
currentPath.value = currentPath.value ? `${currentPath.value}/${dirName}` : dirName;
|
||||
fetchFiles();
|
||||
};
|
||||
|
||||
const navigateUp = () => {
|
||||
if (!currentPath.value) return;
|
||||
const parts = currentPath.value.split('/');
|
||||
parts.pop();
|
||||
currentPath.value = parts.join('/');
|
||||
fetchFiles();
|
||||
};
|
||||
|
||||
const deleteFile = async (name) => {
|
||||
if (!confirm(`确定要删除 ${name} 吗?`)) return;
|
||||
try {
|
||||
const fullPath = currentPath.value ? `${currentPath.value}/${name}` : name;
|
||||
await axios.delete(`/admin/api/files/${fullPath}`);
|
||||
fetchFiles();
|
||||
} catch (e) {
|
||||
alert('删除失败: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerCleanup = async () => {
|
||||
cleaning.value = true;
|
||||
try {
|
||||
const res = await axios.post('/admin/api/cleanup');
|
||||
alert(`清理完成: ${res.data.message}`);
|
||||
fetchFiles(); // 刷新文件列表
|
||||
} catch (e) {
|
||||
alert('清理失败');
|
||||
} finally {
|
||||
cleaning.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const viewResult = (path) => {
|
||||
// path format: "results/subdir/file.jpg" or "results/file.jpg"
|
||||
currentTab.value = 'files';
|
||||
|
||||
// Remove "results/" prefix
|
||||
// Note: path usually comes from backend as "results/..."
|
||||
let relativePath = path;
|
||||
if (relativePath.startsWith('results/')) {
|
||||
relativePath = relativePath.substring(8); // Remove "results/"
|
||||
}
|
||||
|
||||
// Check if it looks like a file (has extension)
|
||||
const isFile = /\.[a-zA-Z0-9]+$/.test(relativePath);
|
||||
|
||||
if (isFile) {
|
||||
// It's a file
|
||||
const lastSlashIndex = relativePath.lastIndexOf('/');
|
||||
let dirPath = '';
|
||||
|
||||
if (lastSlashIndex !== -1) {
|
||||
dirPath = relativePath.substring(0, lastSlashIndex);
|
||||
}
|
||||
|
||||
currentPath.value = dirPath;
|
||||
fetchFiles();
|
||||
|
||||
// Show preview immediately
|
||||
previewUrl.value = '/static/' + path;
|
||||
} else {
|
||||
// It's likely a directory
|
||||
currentPath.value = relativePath;
|
||||
fetchFiles();
|
||||
}
|
||||
};
|
||||
|
||||
const previewImage = (path) => {
|
||||
previewUrl.value = path;
|
||||
};
|
||||
|
||||
const isImage = (name) => {
|
||||
return /\.(jpg|jpeg|png|gif|webp)$/i.test(name);
|
||||
};
|
||||
|
||||
const formatDate = (ts) => {
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
};
|
||||
|
||||
const getDurationClass = (duration) => {
|
||||
if (duration < 2.0) return 'text-green-600 bg-green-50';
|
||||
if (duration < 5.0) return 'text-yellow-600 bg-yellow-50';
|
||||
return 'text-red-600 bg-red-50';
|
||||
};
|
||||
|
||||
const getTypeBadgeClass = (type) => {
|
||||
const map = {
|
||||
'general': 'bg-blue-50 text-blue-600 border border-blue-100',
|
||||
'tarot': 'bg-purple-50 text-purple-600 border border-purple-100',
|
||||
'tarot-recognize': 'bg-indigo-50 text-indigo-600 border border-indigo-100',
|
||||
'face': 'bg-pink-50 text-pink-600 border border-pink-100'
|
||||
};
|
||||
return map[type] || 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
// Watch tab change to fetch data
|
||||
Vue.watch(currentTab, (newTab) => {
|
||||
if (newTab === 'files') fetchFiles();
|
||||
if (newTab === 'dashboard') fetchHistory();
|
||||
if (newTab === 'prompts') fetchPrompts();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
checkLogin();
|
||||
});
|
||||
|
||||
return {
|
||||
isLoggedIn, password, loginError, login, logout,
|
||||
currentTab, history, files, currentPath,
|
||||
enterDir, navigateUp, deleteFile, triggerCleanup,
|
||||
viewResult, previewImage, isImage, previewUrl,
|
||||
formatDate, getDurationClass, getTypeBadgeClass, cleaning, deviceInfo,
|
||||
currentModel, availableModels, updateModel,
|
||||
cleanupConfig, saveCleanupConfig,
|
||||
prompts, fetchPrompts, savePrompt, getPromptDescription
|
||||
};
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 132 KiB |
Reference in New Issue
Block a user