Files
python-template/backend/pdf_image_extractor.py
Claude CodeandClaude 5e5020a38a feat: 大幅优化题目UI设计和图片关联算法
主要改进:
🎨 UI优化
- 实现学而思风格的题目卡片设计
- 优化数学公式和坐标点显示格式
- 添加专业的选项和答案展示
- 改进响应式设计和交互体验

🔧 技术优化
- 修复图片关联算法,避免重复分配
- 智能图片与题目匹配算法
- 添加admin用户便捷登录功能
- 优化题目文本格式化

📊 功能增强
- 可折叠的答案解析区域
- 动态分值提取和显示
- 专业的数学符号高亮
- 子题清晰分隔显示

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-10-17 14:30:52 +08:00

319 lines
12 KiB
Python

"""
PDF图片提取器 - 从PDF中提取嵌入的图片并与题目关联
专门处理数学试卷中的几何图形、函数图像、表格等
"""
import os
import time
import hashlib
import tempfile
from typing import List, Dict, Tuple, Optional
import fitz # PyMuPDF
from PIL import Image
import uuid
class PDFImageExtractor:
def __init__(self, output_dir: str = "uploads/images"):
self.output_dir = output_dir
# 确保输出目录存在
os.makedirs(output_dir, exist_ok=True)
# 图片提取配置
self.min_image_size = (50, 50) # 最小图片尺寸,过滤掉太小的图片
self.max_image_size = (2000, 2000) # 最大图片尺寸
self.supported_formats = ['png', 'jpg', 'jpeg', 'gif', 'bmp']
def extract_images_from_pdf(self, pdf_path: str) -> List[Dict]:
"""
从PDF中提取所有图片
返回图片信息列表,包含图片路径、位置、尺寸等
"""
print(f"🖼️ 开始从PDF提取图片: {pdf_path}")
start_time = time.time()
try:
doc = fitz.open(pdf_path)
extracted_images = []
for page_num in range(len(doc)):
page = doc[page_num]
print(f"📄 处理第{page_num + 1}页...")
# 获取页面中的图片
image_list = page.get_images(full=True)
for img_index, img in enumerate(image_list):
# 提取图片信息
xref = img[0] # 图片引用
# 获取图片数据
base_image = doc.extract_image(xref)
image_bytes = base_image["image"]
image_ext = base_image["ext"]
# 验证图片格式
if image_ext.lower() not in self.supported_formats:
continue
# 生成唯一文件名
image_hash = hashlib.md5(image_bytes).hexdigest()
filename = f"page_{page_num + 1}_img_{img_index + 1}_{image_hash[:8]}.{image_ext}"
image_path = os.path.join(self.output_dir, filename)
# 保存图片
with open(image_path, "wb") as img_file:
img_file.write(image_bytes)
# 获取图片在页面中的位置
image_rects = page.get_image_rects(xref)
# 验证图片尺寸
try:
with Image.open(image_path) as pil_img:
width, height = pil_img.size
# 过滤太小或太大的图片
if (width < self.min_image_size[0] or
height < self.min_image_size[1] or
width > self.max_image_size[0] or
height > self.max_image_size[1]):
os.remove(image_path) # 删除不符合条件的图片
continue
except Exception as e:
print(f"⚠️ 图片验证失败: {e}")
if os.path.exists(image_path):
os.remove(image_path)
continue
# 记录图片信息
image_info = {
"filename": filename,
"path": image_path,
"relative_path": f"images/{filename}", # 用于前端访问
"page": page_num + 1,
"width": width,
"height": height,
"format": image_ext.upper(),
"size_bytes": len(image_bytes),
"hash": image_hash,
"rects": [] # 图片在页面中的位置
}
# 添加位置信息
for rect in image_rects:
image_info["rects"].append({
"x0": rect.x0,
"y0": rect.y0,
"x1": rect.x1,
"y1": rect.y1,
"width": rect.width,
"height": rect.height
})
extracted_images.append(image_info)
print(f"✅ 提取图片: {filename} ({width}x{height})")
doc.close()
total_time = time.time() - start_time
print(f"🎉 图片提取完成: {len(extracted_images)}张图片, 耗时{total_time:.2f}秒")
return extracted_images
except Exception as e:
print(f"❌ PDF图片提取失败: {str(e)}")
return []
def associate_images_with_questions(self, images: List[Dict], questions: List[Dict]) -> List[Dict]:
"""
将图片与题目关联
基于页面位置和题目边界框进行智能匹配
"""
print(f"🔗 开始关联图片与题目: {len(images)}张图片, {len(questions)}道题目")
# 用于跟踪已分配的图片
used_images = set()
# 按页码组织题目
questions_by_page = {}
for i, question in enumerate(questions):
page = question.get("page", 1)
if page not in questions_by_page:
questions_by_page[page] = []
questions_by_page[page].append((i, question))
# 为每页进行图片关联
for page_num, page_questions in questions_by_page.items():
# 获取当前页的图片
page_images = [img for img in images if img["page"] == page_num]
if not page_images:
continue
# 按图片在页面中的垂直位置排序(从上到下)
page_images_sorted = sorted(page_images, key=lambda x: min(rect["y0"] for rect in x["rects"]) if x["rects"] else 0)
# 为每个题目进行图片分配
for question_idx, question in page_questions:
question_images = []
# 获取题目的边界框信息
question_bbox = self._get_question_bbox(question)
# 遍历页面中的图片,寻找合适的匹配
for img_idx, image in enumerate(page_images_sorted):
img_filename = image["filename"]
# 跳过已分配的图片
if img_filename in used_images:
continue
# 检查图片是否适合当前题目
if self._should_associate_image(question, image, question_bbox):
question_images.append({
"filename": image["filename"],
"path": image["relative_path"],
"width": image["width"],
"height": image["height"],
"format": image["format"]
})
used_images.add(img_filename)
print(f"📝 题目{question.get('number', '?')}关联图片: {img_filename}")
break # 每个题目最多关联一张图片,除非明确需要多张
question["images"] = question_images
if not question_images:
print(f"📝 题目{question.get('number', '?')}未关联图片")
return questions
def _get_question_bbox(self, question: Dict) -> Dict:
"""
获取题目的边界框信息
"""
# 尝试从不同字段获取边界框
bbox = question.get("bbox")
if bbox and isinstance(bbox, dict):
return bbox
# 尝试从page_spans获取
page_spans = question.get("page_spans")
if page_spans and len(page_spans) > 0:
span = page_spans[0]
if isinstance(span, dict) and "bbox" in span:
return span["bbox"]
# 默认边界框(整个页面)
return {"x0": 0, "y0": 0, "x1": 1000, "y1": 1000}
def _should_associate_image(self, question: Dict, image: Dict, question_bbox: Dict) -> bool:
"""
判断图片是否应该关联到当前题目
"""
# 策略1: 基于几何位置匹配
if image.get("rects"):
for rect in image["rects"]:
# 检查图片是否在题目的合理范围内
# 考虑页面布局和图片通常的放置位置
img_center_y = (rect["y0"] + rect["y1"]) / 2
question_center_y = (question_bbox.get("y0", 0) + question_bbox.get("y1", 1000)) / 2
# 如果图片中心在题目中心的合理范围内(上下200点)
if abs(img_center_y - question_center_y) < 200:
return True
# 如果图片在题目下方(通常图片紧跟题目)
if rect["y0"] > question_bbox.get("y1", 0) and rect["y0"] - question_bbox.get("y1", 0) < 300:
return True
# 策略2: 基于题目内容判断
question_text = question.get("text", "").lower()
question_type = question.get("type", "")
# 选择题通常较少有图片
if question_type in ["single_choice", "multiple_choice"]:
# 只有明确提到图形时才关联图片
if any(keyword in question_text for keyword in ["如图", "下图", "图形", "图像", "坐标", "函数图像"]):
return True
# 解答题、证明题等更可能有图片
elif question_type in ["essay", "calculation", "proof"]:
return True
# 默认不关联(更保守的策略)
return False
def extract_and_associate(self, pdf_path: str, questions: List[Dict]) -> Tuple[List[Dict], List[Dict]]:
"""
提取PDF图片并与题目关联
返回 (更新后的题目列表, 图片列表)
"""
# 1. 提取图片
images = self.extract_images_from_pdf(pdf_path)
# 2. 关联图片与题目
updated_questions = self.associate_images_with_questions(images, questions)
return updated_questions, images
def clean_unused_images(self, used_images: List[str]):
"""
清理未使用的图片文件
"""
try:
if not os.path.exists(self.output_dir):
return
# 获取所有图片文件
all_files = os.listdir(self.output_dir)
# 删除未使用的文件
for filename in all_files:
if filename not in used_images:
file_path = os.path.join(self.output_dir, filename)
try:
os.remove(file_path)
print(f"🗑️ 清理未使用图片: {filename}")
except Exception as e:
print(f"⚠️ 清理文件失败: {filename}, {e}")
except Exception as e:
print(f"⚠️ 清理图片失败: {e}")
# 测试函数
def test_image_extractor():
"""
测试图片提取器
"""
pdf_path = "/Users/brunogao/Downloads/深圳市八年级(上)期中数学试卷1.pdf"
if not os.path.exists(pdf_path):
print(f"❌ 测试文件不存在: {pdf_path}")
return
extractor = PDFImageExtractor()
# 模拟一些题目数据
sample_questions = [
{"number": "1", "page": 1, "text": "题目1内容"},
{"number": "2", "page": 1, "text": "题目2内容"},
{"number": "3", "page": 2, "text": "题目3内容"},
]
# 提取并关联图片
updated_questions, images = extractor.extract_and_associate(pdf_path, sample_questions)
print(f"\n📊 测试结果:")
print(f"- 提取图片数: {len(images)}")
print(f"- 处理题目数: {len(updated_questions)}")
for question in updated_questions:
if question.get("images"):
print(f"题目{question['number']}: {len(question['images'])}张图片")
for img in question["images"]:
print(f" - {img['filename']} ({img['width']}x{img['height']})")
if __name__ == "__main__":
test_image_extractor()