lovart3/backend-florence/app/services/florence_service.py
2025-12-17 20:15:18 +08:00

614 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import torch
from transformers import AutoProcessor, AutoModelForCausalLM, MarianMTModel, MarianTokenizer
from PIL import Image, ImageDraw
import requests
from io import BytesIO
import asyncio
import re
class FlorenceService:
def __init__(self):
self.model = None
self.processor = None
self.translator_model = None
self.translator_tokenizer = None
self.is_loaded = False
# 🚀 自动检测并使用 GPU(如果可用)
self.device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"🎯 使用设备: {self.device}")
if self.device == "cuda":
print(f"🚀 GPU 加速已启用: {torch.cuda.get_device_name(0)}")
print(f"💾 GPU 显存: {torch.cuda.get_device_properties(0).total_memory / 1024**3:.1f} GB")
print(f"📊 GPU 数量: {torch.cuda.device_count()}")
async def load_model(self):
"""异步加载模型"""
if self.is_loaded:
return
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._load_model_sync)
def _load_model_sync(self):
"""同步加载模型(在线程池中执行)"""
print("📦 加载 Florence-2 模型...")
self.model = AutoModelForCausalLM.from_pretrained(
"microsoft/Florence-2-base",
trust_remote_code=True
).to(self.device)
self.processor = AutoProcessor.from_pretrained(
"microsoft/Florence-2-base",
trust_remote_code=True
)
print("✅ Florence-2 模型加载完成")
# 加载英中翻译模型
print("📦 加载英中翻译模型...")
translator_name = "Helsinki-NLP/opus-mt-en-zh"
self.translator_tokenizer = MarianTokenizer.from_pretrained(translator_name)
self.translator_model = MarianMTModel.from_pretrained(translator_name).to(self.device)
self.is_loaded = True
print("✅ 翻译模型加载完成")
async def detect_object_at_point(
self,
image_url: str,
click_x: int,
click_y: int,
image_index: int,
image_width: int,
image_height: int
) -> dict:
"""检测点击位置的物体(使用大区域裁剪 + 详细描述)"""
# 1. 下载完整图片
print(f"📥 下载图片: {image_url}")
response = requests.get(image_url, timeout=10)
image = Image.open(BytesIO(response.content)).convert("RGB")
actual_width, actual_height = image.size
print(f"📊 图片信息:")
print(f" - 原始尺寸: {actual_width}x{actual_height}")
print(f" - 传入尺寸: {image_width}x{image_height}")
print(f" - 点击坐标: ({click_x}, {click_y})")
# 2. 在图片上绘制标注点,让模型能"看到"用户的标注
marked_image = image.copy()
draw = ImageDraw.Draw(marked_image)
# 绘制醒目的标注标记(红色圆圈 + 十字)
marker_size = 30
# 外圈(红色)
draw.ellipse(
[click_x - marker_size, click_y - marker_size,
click_x + marker_size, click_y + marker_size],
outline=(255, 0, 0),
width=5
)
# 十字线(红色)
draw.line(
[click_x - marker_size - 10, click_y, click_x + marker_size + 10, click_y],
fill=(255, 0, 0),
width=5
)
draw.line(
[click_x, click_y - marker_size - 10, click_x, click_y + marker_size + 10],
fill=(255, 0, 0),
width=5
)
print(f"✏️ 已在图片上绘制标注点: ({click_x}, {click_y})")
# 3. 使用标记后的完整图片进行物体检测
print("🔍 正在检测标记后的完整图片...")
loop = asyncio.get_event_loop()
detected_object = await loop.run_in_executor(
None,
self._detect_object_at_point_sync,
marked_image,
click_x,
click_y
)
# 3. 翻译成中文(如果是英文)
description_cn = await self._translate_to_chinese(detected_object)
# 4. 格式化返回结果(无图标,纯文本)
formatted_text = f"图{image_index}: {description_cn}"
return {
"description": description_cn,
"descriptionEn": detected_object,
"confidence": 0.95, # Florence-2 不返回置信度,固定值
"imageIndex": image_index,
"formattedText": formatted_text
}
def _detect_object_at_point_sync(self, image: Image.Image, click_x: int, click_y: int) -> str:
"""智能裁剪策略:聚焦在点击位置的具体物品,而不是整个人物"""
# 步骤1:使用物体检测找到点击位置的物体边界框
print("🔍 步骤1: 检测所有物体...")
bbox = self._find_object_bbox(image, click_x, click_y)
if bbox:
x1, y1, x2, y2 = bbox
bbox_width = x2 - x1
bbox_height = y2 - y1
print(f"📦 检测到物体边界框: ({x1:.0f}, {y1:.0f}) -> ({x2:.0f}, {y2:.0f})")
print(f" 尺寸: {bbox_width:.0f}x{bbox_height:.0f}")
# 判断:如果是大物体(人物),使用点击点周围的固定区域
# 如果是小物体,使用完整边界框
if bbox_width > 400 or bbox_height > 600:
# 大物体(可能是人物):以点击点为中心裁剪固定区域
print(" → 检测到大物体(人物),聚焦点击位置")
crop_size = 300
x1 = max(0, click_x - crop_size // 2)
y1 = max(0, click_y - crop_size // 2)
x2 = min(image.width, click_x + crop_size // 2)
y2 = min(image.height, click_y + crop_size // 2)
print(f"✂️ 聚焦裁剪: ({x1}, {y1}) -> ({x2}, {y2}), 尺寸: {x2-x1}x{y2-y1}")
else:
# 小物体:使用完整边界框+扩展
print(" → 检测到小物体,使用完整边界框")
padding = 0.15
pad_x = int(bbox_width * padding)
pad_y = int(bbox_height * padding)
x1 = max(0, int(x1 - pad_x))
y1 = max(0, int(y1 - pad_y))
x2 = min(image.width, int(x2 + pad_x))
y2 = min(image.height, int(y2 + pad_y))
print(f"✂️ 扩展边界框: ({x1}, {y1}) -> ({x2}, {y2})")
cropped_region = image.crop((x1, y1, x2, y2))
else:
# 如果没有检测到物体,使用标注点周围的中等区域
print("⚠️ 未检测到物体边界框,使用标注点周围区域")
crop_size = 300
x1 = max(0, click_x - crop_size // 2)
y1 = max(0, click_y - crop_size // 2)
x2 = min(image.width, click_x + crop_size // 2)
y2 = min(image.height, click_y + crop_size // 2)
print(f"✂️ 裁剪区域: ({x1}, {y1}) -> ({x2}, {y2})")
cropped_region = image.crop((x1, y1, x2, y2))
# 步骤2:对裁剪区域进行详细描述
print("🔍 步骤2: 生成详细描述...")
description = self._describe_region(cropped_region)
print(f"✅ 最终识别: {description}")
return description
def _find_object_bbox(self, image: Image.Image, click_x: int, click_y: int):
"""使用物体检测找到点击位置的物体边界框"""
import time
start_time = time.time()
prompt = "<OD>"
inputs = self.processor(
text=prompt,
images=image,
return_tensors="pt"
)
# 显式移动所有tensor到GPU
inputs = {k: v.to(self.device) for k, v in inputs.items()}
print(f" → 推理设备: {next(self.model.parameters()).device}")
with torch.no_grad():
generated_ids = self.model.generate(
input_ids=inputs["input_ids"],
pixel_values=inputs["pixel_values"],
max_new_tokens=1024,
num_beams=3,
do_sample=False
)
elapsed = time.time() - start_time
print(f" → 物体检测耗时: {elapsed:.2f}秒")
generated_text = self.processor.batch_decode(
generated_ids,
skip_special_tokens=False
)[0]
parsed_results = self.processor.post_process_generation(
generated_text,
task="<OD>",
image_size=(image.width, image.height)
)
if parsed_results and '<OD>' in parsed_results:
od_result = parsed_results['<OD>']
bboxes = od_result.get('bboxes', [])
labels = od_result.get('labels', [])
print(f" 检测到 {len(bboxes)} 个物体")
# 找到包含点击点的边界框
for i, (bbox, label) in enumerate(zip(bboxes, labels)):
x1, y1, x2, y2 = bbox
if x1 <= click_x <= x2 and y1 <= click_y <= y2:
print(f" ✓ 找到匹配物体 {i+1}: {label}")
return bbox
# 如果没有包含的,找最近的
print(" 未找到包含点击点的物体,寻找最近的...")
min_distance = float('inf')
closest_bbox = None
for bbox in bboxes:
x1, y1, x2, y2 = bbox
center_x = (x1 + x2) / 2
center_y = (y1 + y2) / 2
distance = ((center_x - click_x) ** 2 + (center_y - click_y) ** 2) ** 0.5
if distance < min_distance:
min_distance = distance
closest_bbox = bbox
if closest_bbox:
print(f" ✓ 最近物体距离: {min_distance:.0f}px")
return closest_bbox
return None
def _describe_region(self, region_image: Image.Image) -> str:
"""对区域图像进行详细描述"""
import time
start_time = time.time()
prompt = "<MORE_DETAILED_CAPTION>"
inputs = self.processor(
text=prompt,
images=region_image,
return_tensors="pt"
)
# 显式移动所有tensor到GPU
inputs = {k: v.to(self.device) for k, v in inputs.items()}
with torch.no_grad():
generated_ids = self.model.generate(
input_ids=inputs["input_ids"],
pixel_values=inputs["pixel_values"],
max_new_tokens=50,
num_beams=3,
do_sample=False
)
elapsed = time.time() - start_time
print(f" → 描述生成耗时: {elapsed:.2f}秒")
generated_text = self.processor.batch_decode(
generated_ids,
skip_special_tokens=True
)[0]
print(f" 原始描述: {generated_text}")
# 提取核心物品名称
description = self._extract_item_name(generated_text)
return description
def _extract_item_name(self, description: str) -> str:
"""从详细描述中提取核心物品名称"""
# 移除常见的前缀短语(按长度排序,先匹配长的)
remove_phrases = [
"the image is a promotional poster ",
"the image is a portrait ",
"this image shows ",
"the image shows ",
"the image is a ",
"the image is ",
"a close-up of ",
"a close up of ",
"close-up of ",
"close up of ",
"an image of ",
"a picture of ",
"a photo of ",
"there is a ",
"there is ",
"this is a ",
"this is ",
"it is a ",
"it is ",
]
description_lower = description.lower().strip()
# 持续删除前缀,直到没有匹配的
changed = True
while changed:
changed = False
for phrase in remove_phrases:
if description_lower.startswith(phrase):
description_lower = description_lower[len(phrase):].strip()
changed = True
break
# 取前4-5个词作为物品名称
words = description_lower.split()
if len(words) >= 4:
item_name = " ".join(words[:4])
elif len(words) >= 2:
item_name = " ".join(words[:2])
else:
item_name = description_lower
return item_name.strip()
def _detect_with_context(self, image: Image.Image) -> str:
"""使用大区域上下文识别主要物体(在线程池中执行)"""
# 使用 <MORE_DETAILED_CAPTION> 生成详细描述,包含颜色、材质等
prompt = "<MORE_DETAILED_CAPTION>"
print(f"🎯 使用 prompt: {prompt}")
inputs = self.processor(
text=prompt,
images=image,
return_tensors="pt"
).to(self.device)
# 生成描述
with torch.no_grad():
generated_ids = self.model.generate(
input_ids=inputs["input_ids"],
pixel_values=inputs["pixel_values"],
max_new_tokens=40, # 增加长度以获取更多细节
num_beams=3,
do_sample=False
)
# 解码结果
generated_text = self.processor.batch_decode(
generated_ids,
skip_special_tokens=False
)[0]
# 提取描述(去除提示词和特殊标记)
description = generated_text.replace(prompt, "").strip()
description = description.replace("</s>", "").replace("<s>", "").strip()
description = " ".join(description.split())
# 🎯 提取核心信息(颜色+物体)
description = self._extract_core_description(description)
print(f"✅ 识别结果(带上下文): {description}")
return description
def _detect_region_sync(self, image: Image.Image, bbox: tuple) -> str:
"""使用区域标注识别完整图片中的指定区域(在线程池中执行)"""
# 使用 <REGION_TO_CAPTION> 任务,识别完整图片中的指定区域
# bbox 格式:(x1, y1, x2, y2),归一化到 [0, 1000]
x1, y1, x2, y2 = bbox
prompt = f"<REGION_TO_CAPTION><loc_{x1}><loc_{y1}><loc_{x2}><loc_{y2}>"
print(f"🎯 使用 prompt: {prompt}")
inputs = self.processor(
text=prompt,
images=image,
return_tensors="pt"
).to(self.device)
# 生成描述
with torch.no_grad():
generated_ids = self.model.generate(
input_ids=inputs["input_ids"],
pixel_values=inputs["pixel_values"],
max_new_tokens=30,
num_beams=3,
do_sample=False
)
# 解码结果
generated_text = self.processor.batch_decode(
generated_ids,
skip_special_tokens=False
)[0]
# 提取描述(去除提示词和特殊标记)
# Florence-2 返回格式: <REGION_TO_CAPTION>description</s>
description = generated_text.replace("<REGION_TO_CAPTION>", "").strip()
# 清理特殊标记和位置标记
description = re.sub(r'<loc_\d+>', '', description)
description = description.replace("</s>", "").replace("<s>", "").strip()
description = " ".join(description.split())
# 🎯 提取核心信息(颜色+物体)
description = self._extract_core_description(description)
print(f"✅ 区域识别结果: {description}")
return description
def _detect_sync(self, image: Image.Image) -> str:
"""同步推理(在线程池中执行)- 已弃用,使用 _detect_region_sync"""
# 使用 <MORE_DETAILED_CAPTION> 生成更详细的描述,包含颜色和细节
prompt = "<MORE_DETAILED_CAPTION>"
inputs = self.processor(
text=prompt,
images=image,
return_tensors="pt"
).to(self.device)
# 生成描述
with torch.no_grad():
generated_ids = self.model.generate(
input_ids=inputs["input_ids"],
pixel_values=inputs["pixel_values"],
max_new_tokens=30, # 增加长度以获取更多细节
num_beams=3,
do_sample=False
)
# 解码结果
generated_text = self.processor.batch_decode(
generated_ids,
skip_special_tokens=False
)[0]
# 提取描述(去除提示词和特殊标记)
description = generated_text.replace(prompt, "").strip()
# 清理特殊标记
description = description.replace("</s>", "").replace("<s>", "").strip()
# 清理多余的空格
description = " ".join(description.split())
# 🎯 提取核心信息(颜色+物体)
description = self._extract_core_description(description)
print(f"✅ 识别结果: {description}")
return description
def _extract_core_description(self, text: str) -> str:
"""提取核心描述(只保留主要物体名称)"""
# 移除所有介绍性和描述性短语(包括摄影术语)
remove_phrases = [
"a close up of ", # 特写镜头(优先匹配)
"a close-up of ",
"close up of ",
"close-up of ",
"the image shows ",
"the photo shows ",
"this is a ",
"this is an ",
"this is ",
"there is a ",
"there is an ",
"there is ",
"there are ",
"a picture of ",
"an image of ",
"it shows ",
"shows ",
"depicts ",
"featuring ",
"with ",
" on top of ",
" on a ",
" on the ",
" in a ",
" in the ",
" sitting on ",
" lying on ",
" placed on ",
" next to ",
" beside ",
]
result = text.lower()
# 先移除介绍性短语(按长度降序,优先匹配长短语)
for phrase in sorted(remove_phrases, key=len, reverse=True):
result = result.replace(phrase, " ")
# 只保留第一个逗号或句号之前的内容
for sep in [". ", ", ", " and ", " with "]:
if sep in result:
result = result.split(sep)[0]
break
# 移除冠词
result = result.replace(" a ", " ").replace(" an ", " ").replace(" the ", " ")
# 清理多余空格
result = " ".join(result.split()).strip()
# 限制长度(最多3-4个词)
words = result.split()
if len(words) > 4:
result = " ".join(words[:4])
return result
async def _translate_to_chinese(self, text: str) -> str:
"""使用关键词映射进行准确翻译(替代MarianMT)"""
if not text or not text.strip():
return text
# 如果已经是中文,直接返回
if any('\u4e00' <= char <= '\u9fff' for char in text):
return text
try:
# 使用关键词映射
translated = self._translate_with_keywords(text)
print(f"🌐 翻译: {text} -> {translated}")
return translated
except Exception as e:
print(f"⚠️ 翻译失败: {e},返回原文")
return text
def _translate_with_keywords(self, text: str) -> str:
"""使用关键词映射进行翻译"""
keyword_map = {
# 家具
"chair": "椅子", "sofa": "沙发", "couch": "沙发",
"bean bag": "懒人沙发", "beanbag": "懒人沙发",
"table": "桌子", "desk": "书桌", "bed": "床",
# 织物
"towel": "毛巾", "pillow": "枕头", "cushion": "靠垫",
"blanket": "毯子", "throw": "抱枕",
"rug": "地毯", "carpet": "地毯", "mat": "垫子",
"fabric": "织物", "knit": "针织", "knitted": "针织",
"fluffy": "毛绒", "fuzzy": "毛茸茸",
# 颜色
"green": "绿色", "red": "红色", "blue": "蓝色",
"yellow": "黄色", "white": "白色", "black": "黑色",
"brown": "棕色", "pink": "粉色", "beige": "米色",
"gray": "灰色", "grey": "灰色",
# 物品
"book": "书", "books": "书籍", "magazine": "杂志",
"stack": "堆叠", "pile": "堆", "bottle": "瓶子",
"plant": "植物", "vase": "花瓶",
# 衣物
"sweater": "毛衣", "shirt": "衬衫",
"jacket": "夹克", "coat": "外套",
"dress": "连衣裙", "scarf": "围巾",
# 形状
"heart": "心形", "round": "圆形",
"square": "方形",
# 状态
"lying": "放置", "sitting": "放置",
"resting": "靠着", "open": "打开",
"with": "带有", "and": "和",
}
words = text.lower().split()
translated_words = []
for word in words:
clean_word = word.strip('.,!?;:')
translated_words.append(keyword_map.get(clean_word, word))
return " ".join(translated_words)