主要改进: 🎨 UI优化 - 实现学而思风格的题目卡片设计 - 优化数学公式和坐标点显示格式 - 添加专业的选项和答案展示 - 改进响应式设计和交互体验 🔧 技术优化 - 修复图片关联算法,避免重复分配 - 智能图片与题目匹配算法 - 添加admin用户便捷登录功能 - 优化题目文本格式化 📊 功能增强 - 可折叠的答案解析区域 - 动态分值提取和显示 - 专业的数学符号高亮 - 子题清晰分隔显示 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
319 lines
12 KiB
Python
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() |