212 lines
7.5 KiB
Python
212 lines
7.5 KiB
Python
"""
|
||
图片处理工具模块
|
||
支持图片的格式转换、压缩、缩略图生成等功能
|
||
"""
|
||
|
||
import base64
|
||
import io
|
||
|
||
from PIL import ExifTags, Image
|
||
|
||
from yuxi.utils import logger
|
||
|
||
|
||
class ImageProcessor:
|
||
"""图片处理类"""
|
||
|
||
# 支持的图片格式
|
||
SUPPORTED_FORMATS = {"JPEG", "PNG", "WebP", "GIF", "BMP"}
|
||
|
||
# 最大文件大小(5MB)
|
||
MAX_FILE_SIZE = 5 * 1024 * 1024
|
||
|
||
# 缩略图尺寸
|
||
THUMBNAIL_SIZE = (200, 200)
|
||
|
||
def process_image(self, image_data: bytes, original_filename: str = "") -> dict:
|
||
"""
|
||
处理上传的图片
|
||
|
||
Args:
|
||
image_data: 图片二进制数据
|
||
original_filename: 原始文件名
|
||
|
||
Returns:
|
||
dict: 包含处理结果的字典
|
||
"""
|
||
try:
|
||
# 验证图片格式
|
||
img_format, _ = self._validate_image_format(image_data)
|
||
if img_format not in self.SUPPORTED_FORMATS:
|
||
raise ValueError(f"不支持的图片格式: {img_format}")
|
||
|
||
# 加载图片
|
||
with Image.open(io.BytesIO(image_data)) as img:
|
||
# 处理EXIF方向信息
|
||
img = self._fix_image_orientation(img)
|
||
|
||
# 生成缩略图
|
||
thumbnail_data = self._generate_thumbnail(img)
|
||
|
||
# 压缩主图片(如果需要)
|
||
processed_data, final_format = self._compress_image(img, img_format)
|
||
|
||
# 转换为 base64
|
||
base64_data = base64.b64encode(processed_data).decode("utf-8")
|
||
base64_thumbnail = base64.b64encode(thumbnail_data).decode("utf-8")
|
||
|
||
# 获取图片信息
|
||
width, height = img.size
|
||
mime_type = f"image/{final_format.lower()}"
|
||
|
||
return {
|
||
"success": True,
|
||
"image_content": base64_data,
|
||
"thumbnail_content": base64_thumbnail,
|
||
"width": width,
|
||
"height": height,
|
||
"format": final_format,
|
||
"mime_type": mime_type,
|
||
"size_bytes": len(processed_data),
|
||
"original_filename": original_filename,
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"图片处理失败: {str(e)}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def _validate_image_format(self, image_data: bytes) -> tuple[str, str]:
|
||
"""验证图片格式并返回格式信息"""
|
||
try:
|
||
with Image.open(io.BytesIO(image_data)) as img:
|
||
return img.format, img.mode
|
||
except Exception as e:
|
||
raise ValueError(f"无效的图片格式: {str(e)}")
|
||
|
||
def _fix_image_orientation(self, img: Image.Image) -> Image.Image:
|
||
"""根据EXIF信息修正图片方向"""
|
||
try:
|
||
if hasattr(img, "_getexif"):
|
||
exif = img._getexif()
|
||
if exif is not None:
|
||
for tag, value in exif.items():
|
||
if tag in ExifTags.TAGS and ExifTags.TAGS[tag] == "Orientation":
|
||
if value == 3:
|
||
img = img.rotate(180, expand=True)
|
||
elif value == 6:
|
||
img = img.rotate(270, expand=True)
|
||
elif value == 8:
|
||
img = img.rotate(90, expand=True)
|
||
break
|
||
except Exception as e:
|
||
logger.warning(f"修正图片方向失败,使用原始方向: {str(e)}")
|
||
|
||
return img
|
||
|
||
def _generate_thumbnail(self, img: Image.Image) -> bytes:
|
||
"""生成缩略图"""
|
||
try:
|
||
thumbnail = self._convert_to_rgb_for_export(img)
|
||
|
||
# 生成缩略图,保持宽高比
|
||
thumbnail.thumbnail(self.THUMBNAIL_SIZE, Image.Resampling.LANCZOS)
|
||
|
||
# 转换为JPEG格式
|
||
with io.BytesIO() as output:
|
||
thumbnail.save(output, format="JPEG", quality=85, optimize=True)
|
||
return output.getvalue()
|
||
|
||
except Exception as e:
|
||
logger.error(f"生成缩略图失败: {str(e)}")
|
||
# 如果缩略图生成失败,返回一个1x1的透明图片
|
||
with io.BytesIO() as output:
|
||
empty_img = Image.new("RGB", (1, 1), color="white")
|
||
empty_img.save(output, format="JPEG", quality=85)
|
||
return output.getvalue()
|
||
|
||
def _convert_to_rgb_for_export(self, img: Image.Image) -> Image.Image:
|
||
"""转换为 RGB,同时把透明像素按白底合成,避免隐藏颜色变成可见像素。"""
|
||
if img.mode == "RGB":
|
||
return img.copy()
|
||
|
||
has_alpha = img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info)
|
||
if not has_alpha:
|
||
return img.convert("RGB")
|
||
|
||
rgba_img = img.convert("RGBA")
|
||
background = Image.new("RGBA", rgba_img.size, (255, 255, 255, 255))
|
||
background.alpha_composite(rgba_img)
|
||
return background.convert("RGB")
|
||
|
||
def _compress_image(self, img: Image.Image, original_format: str) -> tuple[bytes, str]:
|
||
"""
|
||
压缩图片,如果超过大小限制
|
||
|
||
Args:
|
||
img: PIL Image对象
|
||
original_format: 原始格式
|
||
|
||
Returns:
|
||
Tuple[bytes, str]: (压缩后的图片数据, 最终格式)
|
||
"""
|
||
processed_img = self._convert_to_rgb_for_export(img)
|
||
|
||
# 尝试保持原始格式,但优先使用JPEG(更好的压缩)
|
||
target_format = "JPEG" if original_format != "PNG" else "PNG"
|
||
|
||
# 初始质量设置
|
||
quality = 85
|
||
|
||
with io.BytesIO() as output:
|
||
# 第一次保存以检查大小
|
||
processed_img.save(output, format=target_format, quality=quality, optimize=True)
|
||
compressed_data = output.getvalue()
|
||
|
||
# 如果文件大小合适,直接返回
|
||
if len(compressed_data) <= self.MAX_FILE_SIZE:
|
||
return compressed_data, target_format
|
||
|
||
# 如果文件太大,逐步降低质量
|
||
while len(compressed_data) > self.MAX_FILE_SIZE and quality > 10:
|
||
quality -= 10
|
||
output.seek(0)
|
||
output.truncate(0)
|
||
processed_img.save(output, format=target_format, quality=quality, optimize=True)
|
||
compressed_data = output.getvalue()
|
||
|
||
# 如果质量降到最低仍然太大,尝试缩小尺寸
|
||
if len(compressed_data) > self.MAX_FILE_SIZE:
|
||
# 逐步缩小尺寸
|
||
scale_factor = 0.9
|
||
while len(compressed_data) > self.MAX_FILE_SIZE and scale_factor > 0.3:
|
||
new_width = int(processed_img.width * scale_factor)
|
||
new_height = int(processed_img.height * scale_factor)
|
||
resized_img = processed_img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||
|
||
output.seek(0)
|
||
output.truncate(0)
|
||
resized_img.save(output, format=target_format, quality=85, optimize=True)
|
||
compressed_data = output.getvalue()
|
||
|
||
scale_factor -= 0.1
|
||
|
||
return compressed_data, target_format
|
||
|
||
|
||
# 全局实例
|
||
image_processor = ImageProcessor()
|
||
|
||
|
||
def process_uploaded_image(image_data: bytes, filename: str = "") -> dict:
|
||
"""
|
||
处理上传的图片(便捷函数)
|
||
|
||
Args:
|
||
image_data: 图片二进制数据
|
||
filename: 文件名
|
||
|
||
Returns:
|
||
dict: 处理结果
|
||
"""
|
||
return image_processor.process_image(image_data, filename)
|