Files
lc_stylist_agent/app/server/utils/img_operation.py

189 lines
7.5 KiB
Python
Raw Normal View History

2025-10-24 10:37:19 +08:00
import logging
import os
2025-10-24 10:37:19 +08:00
from typing import List, Dict
from PIL import Image, ImageDraw, ImageFont
from app.server.utils.minio_client import oss_get_image, minio_client
from app.config import settings
2025-10-24 10:37:19 +08:00
logger = logging.getLogger(__name__)
# 9个 341x341 左右的单元格 (ALL_9_CELLS)
# 布局顺序: 从上到下,从左到右 (1 -> 9)
ALL_9_CELLS = [
# Top Row (Y=0, H=341)
(0, 0, 341, 341), # 1. Top-Left (341x341)
(341, 0, 341, 341), # 2. Top-Middle (341x341)
(682, 0, 342, 341), # 3. Top-Right (342x341)
# Middle Row (Y=341, H=341)
(0, 341, 341, 341), # 4. Mid-Left (341x341)
(341, 341, 341, 341), # 5. Center (341x341)
(682, 341, 342, 341), # 6. Mid-Right (342x341)
# Bottom Row (Y=682, H=342)
(0, 682, 341, 342), # 7. Bottom-Left (341x342)
(341, 682, 341, 342), # 8. Bottom-Middle (341x342)
(682, 682, 342, 342) # 9. Bottom-Right (342x342)
]
def merge_images_to_square(outfit_items: List[Dict[str, str]], max_len=9, add_text=True):
"""
Loads up to 4 images from the given paths, resizes them while maintaining
aspect ratio, and merges them onto a 1024x1024 white background JPG.
The layout depends on the number of images:
1: Center the single image on the 1024x1024 canvas.
2: Place side-by-side, each scaled to fit a 512x1024 half.
3: Place in top-left (512x512), top-right (512x512), and bottom-left (512x512).
4: Place in all four 512x512 quadrants.
Args:
outfit_items: A list of item metadata (max length 9).
Returns:
The file path of the temporary merged JPG image.
"""
# Define the final canvas size
CANVAS_SIZE = 1024
# 定义每个 item 的外边距
MARGIN = 5 # 5像素外边距
2025-10-24 10:37:19 +08:00
# 1. Create the final white canvas
# Using 'RGB' mode for JPG output
canvas = Image.new('RGB', (CANVAS_SIZE, CANVAS_SIZE), 'white')
draw = ImageDraw.Draw(canvas)
font = ImageFont.load_default()
# 2. Define the quadrants/target areas (x, y, w, h)
# The positions are based on a 512x512 quadrant size
quadrants = {
1: [(0, 0, CANVAS_SIZE, CANVAS_SIZE)], # Single full-size placement
2: [(0, 0, 512, CANVAS_SIZE), (512, 0, 512, CANVAS_SIZE)], # Left, Right
3: [(0, 0, 512, 512), (512, 0, 512, 512), (0, 512, 512, 512)], # Top-Left, Top-Right, Bottom-Left
4: [(0, 0, 512, 512), (512, 0, 512, 512), (0, 512, 512, 512), (512, 512, 512, 512)], # All Four
5: ALL_9_CELLS[:5], # 布局前5个单元格 (1-5)
6: ALL_9_CELLS[:6], # 布局前6个单元格 (1-6)
7: ALL_9_CELLS[:7], # 布局前7个单元格 (1-7)
8: ALL_9_CELLS[:8], # 布局前8个单元格 (1-8)
9: ALL_9_CELLS[:9] # 布局全部9个单元格 (1-9)
}
# 3. Load and Filter Images
valid_images = []
image_paths = [item['image_path'] for item in outfit_items]
for path in image_paths:
try:
# We use Image.open() and convert to 'RGB' to handle potential transparency (RGBA)
# and ensure compatibility with the final 'RGB' canvas and JPG output.
if settings.LOCAL == 1:
img = Image.open(path).convert('RGB')
else:
img_name = path.rsplit('/', 1)[-1]
img = oss_get_image(oss_client=minio_client, path=f"{settings.MINIO_LC_DATA_PATH}/{img_name}", data_type="PIL").convert('RGB')
2025-10-24 10:37:19 +08:00
# img = Image.open(path).convert('RGB')
valid_images.append(img)
except Exception as e:
logger.error(f"Error loading image {path}. Skipping: {e}")
num_images = len(valid_images)
if num_images == 0:
raise ValueError("No valid images were loaded.")
if num_images > max_len:
valid_images = valid_images[:max_len]
print(f"Valid item number {num_images} exceed max limit {max_len}, only first {max_len} items will be processed.")
2025-10-24 10:37:19 +08:00
# Get the correct list of target areas based on the number of valid images
target_areas = quadrants.get(num_images, [])
if not target_areas:
raise ValueError(f"No layout defined for {num_images} images.")
2025-10-24 10:37:19 +08:00
# 4. Resize and Paste
for i, (img, item) in enumerate(zip(valid_images, outfit_items)):
item_id = item['item_id']
category = item['category']
subcategory = item['subcategory']
2025-10-24 10:37:19 +08:00
if i >= len(target_areas):
# This should not happen if num_images <= 4
break
# 原始目标区域 (x_start, y_start, width, height)
orig_x_start, orig_y_start, orig_w, orig_h = target_areas[i]
# 📢 应用边距:实际用于图像和文本的区域
# 新的起始点:向内移动 MARGIN
x_start = orig_x_start + MARGIN
y_start = orig_y_start + MARGIN
# 新的宽高:减去两倍的 MARGIN (左右/上下)
target_w = orig_w - 2 * MARGIN
target_h = orig_h - 2 * MARGIN
# --- 图像缩放与居中 ---
2025-10-24 10:37:19 +08:00
# Calculate new size while maintaining aspect ratio
original_w, original_h = img.size
# Calculate the ratio needed to fit within the *带边距的* 目标区域
2025-10-24 10:37:19 +08:00
ratio_w = target_w / original_w
ratio_h = target_h / original_h
# Use the *smaller* of the two ratios to ensure the image fits entirely
resize_ratio = min(ratio_w, ratio_h)
# Calculate the new dimensions
new_w = int(original_w * resize_ratio)
new_h = int(original_h * resize_ratio)
# Resize the image.
2025-10-24 10:37:19 +08:00
resized_img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
# Calculate the paste position to center the resized image within its target area
# Center X: (Target Width - New Width) / 2 + X Start (带边距的 X_start)
2025-10-24 10:37:19 +08:00
paste_x = (target_w - new_w) // 2 + x_start
# 预留文本高度 ( TEXT_RESERVE_HEIGHT )
2025-10-24 10:37:19 +08:00
TEXT_RESERVE_HEIGHT = 30
# Center Y: (Target Height - New Height - 预留文本高度) / 2 + Y Start (带边距的 Y_start)
2025-10-24 10:37:19 +08:00
paste_y = (target_h - new_h - TEXT_RESERVE_HEIGHT) // 2 + y_start
# 确保图片顶部不超出目标区域的 Y_start
2025-10-24 10:37:19 +08:00
paste_y = max(paste_y, y_start)
# Paste the resized image onto the canvas
canvas.paste(resized_img, (paste_x, paste_y))
# --- 文本居中与定位 ---
full_text = f"ID: {item_id}, Subcategory: {subcategory}"
2025-10-24 10:37:19 +08:00
if add_text:
try:
# 推荐使用:计算文本的实际尺寸 (width, height)
bbox = draw.textbbox((0, 0), full_text, font=font)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
except AttributeError:
# 兼容旧版本 Pillow
text_w, text_h = draw.textsize(full_text, font=font)
# 计算 X 轴起始位置:使其在 *带边距的目标区域* (target_w) 中居中
text_x_center = x_start + target_w // 2
text_x_start = text_x_center - text_w // 2
# 计算 Y 轴起始位置:将其放在 *带边距的目标区域* 的底部
# (带边距的起始Y + 带边距的高度 - 文本行的高度)
# 📢 在带边距的目标区域底部再减去 5 像素作为与底部的边距
text_y_start = y_start + target_h - text_h
2025-10-24 10:37:19 +08:00
draw.text((text_x_start, text_y_start),
full_text,
fill='black',
font=font)
# Save as a high-quality JPG (quality=90 is a good balance)
# canvas.save(output_path, 'JPEG', quality=90)
return canvas