import logging import os 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.server.utils.minio_config import MINIO_LC_DATA_PATH from app.config import settings 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像素外边距 # 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: image_file_path = os.path.join(settings.LOCAL_IMAGE_DIR, path) img = Image.open(image_file_path).convert('RGB') else: img = oss_get_image(oss_client=minio_client, path=f"{MINIO_LC_DATA_PATH}/{path}", data_type="PIL").convert('RGB') # 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: raise ValueError(f"Valid item number {num_images} exceed max limit {max_len}") # 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.") # 4. Resize and Paste for i, (img, item) in enumerate(zip(valid_images, outfit_items)): item_id = item['item_id'] category = item['category'] 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 # --- 图像缩放与居中 --- # Calculate new size while maintaining aspect ratio original_w, original_h = img.size # Calculate the ratio needed to fit within the *带边距的* 目标区域 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. 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) paste_x = (target_w - new_w) // 2 + x_start # 预留文本高度 ( TEXT_RESERVE_HEIGHT ) TEXT_RESERVE_HEIGHT = 30 # Center Y: (Target Height - New Height - 预留文本高度) / 2 + Y Start (带边距的 Y_start) paste_y = (target_h - new_h - TEXT_RESERVE_HEIGHT) // 2 + y_start # 确保图片顶部不超出目标区域的 Y_start 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}, Category: {category}" 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 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 # 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 # # # 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. # img = oss_get_image(oss_client=minio_client, path=f"{MINIO_LC_DATA_PATH}/{path}", data_type="PIL").convert('RGB') # # 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: # raise ValueError(f"Valid item number {num_images} exceed max limit {max_len}") # # # Get the correct list of target areas based on the number of valid images # target_areas = quadrants.get(num_images, []) # # # 4. Resize and Paste # for i, (img, item) in enumerate(zip(valid_images, outfit_items)): # item_id = item['item_id'] # category = item['category'] # if i >= len(target_areas): # # This should not happen if num_images <= 4 # break # # # Target area dimensions (x_start, y_start, width, height) # x_start, y_start, target_w, target_h = target_areas[i] # # # Calculate new size while maintaining aspect ratio # original_w, original_h = img.size # # # Calculate the ratio needed to fit within the target area # 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. Image.Resampling.LANCZOS provides high-quality scaling. # # Pillow documentation recommends ANTIALIAS or BICUBIC for downscaling, # # but LANCZOS is a good general high-quality filter. # # Note: In Pillow versions > 9.0.0, Image.LANCZOS is now Image.Resampling.LANCZOS # 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 # paste_x = (target_w - new_w) // 2 + x_start # # Center Y: (Target Height - New Height) / 2 + Y Start # # paste_y = (target_h - new_h) // 2 + y_start # # TEXT_RESERVE_HEIGHT = 30 # paste_y = (target_h - new_h - TEXT_RESERVE_HEIGHT) // 2 + y_start # 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}, Category: {category}" # 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 + 目标区域的高度 - 文本行的高度) # text_y_start = y_start + target_h - text_h - 5 # 减去 5 像素作为边距 # # # 3. 绘制合并后的文本 # if add_text: # 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