# -*- coding: utf-8 -*- """ 鞋类主图合成 基于底图模板(如“粉色布景 + 左侧浅色弧形区域”的底图,即需求中的图2),合成三块区域: A区域:顶部 logo —— 固定顶部位置,宽度不超过底图浅色区域在该位置的宽度 B区域:鞋款色卡列 —— 不同款色的鞋子(数量不固定:1款、5款、更多均可), 在垂直方向按数量等分放置,每只鞋下方(或右侧)放置该款鞋的颜色文本 C区域:最底部文本信息 —— 圆角信息条(货号/尺码/装箱等) 浅色区域宽度由本模块自动检测(对模板左侧浅色像素逐行求右缘),无需手工量坐标。 仅依赖 Pillow + numpy。logo、鞋子图建议使用带透明通道的 PNG(合成前自动裁切到 alpha 最小边)。 用法示例: from shoe_main_image_compose import compose_shoe_main_image compose_shoe_main_image( template=r"底图.png", logo=r"logo.png", shoes=[ {"image": r"鞋_米白.png", "color": "米白"}, {"image": r"鞋_粉色.png", "color": "粉色"}, {"image": r"鞋_蓝色.png", "color": "蓝色"}, ], info_lines=[ ("货号(中童)", "1025B-17"), ("尺码 Size", "24-29#"), ("装箱 Box", "60双"), ], output_path=r"output.png", ) """ import os, settings, requests from io import BytesIO import numpy as np from PIL import Image, ImageDraw, ImageFont # ---------------------------------------------------------------- 基础工具 -- def _to_image(im_or_path): """path 或 PIL.Image -> PIL.Image""" if isinstance(im_or_path, str): return Image.open(im_or_path) return im_or_path def _load_font(font_path, size): """加载中文字体,未指定时按 项目resources -> 系统字体 顺序回退""" candidates = [] if font_path: candidates.append(font_path) here = os.path.dirname(os.path.abspath(__file__)) candidates += [ os.path.join(here, "..", "resources", "ttf", "simhei.ttf"), os.path.join("resources", "ttf", "simhei.ttf"), r"C:\Windows\Fonts\simhei.ttf", r"C:\Windows\Fonts\msyh.ttc", ] for p in candidates: if p and os.path.exists(p): try: return ImageFont.truetype(p, size) except Exception: continue return ImageFont.load_default() def _save(canvas, path): d = os.path.dirname(os.path.abspath(path)) if d: os.makedirs(d, exist_ok=True) ext = os.path.splitext(path)[1].lower() if ext in (".jpg", ".jpeg"): canvas.convert("RGB").save(path, format="JPEG", quality=92) else: canvas.save(path, format="PNG") # 保留 RGBA 透明通道 def _detect_band_widths(canvas, band_color=None, tolerance=30): """ 检测底图左侧浅色区域(如奶油色弧带)的逐行右缘。 返回 (widths, band_color): widths np.array, len=H, widths[y] = 第 y 行浅色区域的右缘 x band_color 实际使用的浅色基准色 (r, g, b) band_color 为 None 时,从模板左上角采样取中值作为基准色。 """ a = np.asarray(canvas) rgb = a[:, :, :3].astype(float) # 只用 RGB 通道做颜色判定 alpha = a[:, :, 3] if a.shape[2] >= 4 else None # 有透明通道时排除透明像素 H, W = rgb.shape[:2] if band_color is None: sample = rgb[4:64, 4:44, :].reshape(-1, 3) bc = np.median(sample, axis=0) else: bc = np.asarray(band_color, dtype=float) # 双条件匹配:颜色距离 + 色度差(通道极差 max-min)。 # 单靠颜色距离时,比奶油色"更白"的纯白背景(距离 < tolerance)会被误判成浅色区, # 导致带宽被高估到 W*0.6 上限。奶油色带暖调(极差≈24),纯白≈0、深藏青≈15, # 用 |像素极差 - 基准极差| < cf_tol 即可排除白底,同时兼容 奶油→藏青 等其它配色。 dist = np.abs(rgb - bc).max(axis=2) band_cf = float(bc.max() - bc.min()) cf = rgb.max(axis=2) - rgb.min(axis=2) cf_tol = max(6.0, band_cf * 0.5) match = (dist < tolerance) & (np.abs(cf - band_cf) < cf_tol) if alpha is not None: match = match & (alpha > 128) # 透明区(模板右侧)不算浅色区 match = match.astype(np.int8) win = 21 kernel = np.ones(win) limit = int(W * 0.6) # 浅色区域不会超过图宽的 60% widths = np.zeros(H, dtype=int) for y in range(H): c = np.convolve(match[y], kernel, mode="same") cands = np.where(c >= win * 0.6)[0] cands = cands[cands < limit] widths[y] = int(cands.max()) if cands.size else 0 # 中值平滑,消除单行噪声 sm = np.zeros_like(widths) for y in range(H): lo, hi = max(0, y - 6), min(H, y + 7) sm[y] = int(np.median(widths[lo:hi])) widths = sm # 兜底:没检测到浅色区域时按图宽 32% 处理 if int(widths.max()) < 40: widths = np.full(H, int(W * 0.32), dtype=int) return widths, tuple(int(v) for v in bc) def _dominant_color(im): """取带透明通道图片的主色(用于颜色文本前的圆点)""" try: a = np.asarray(im.convert("RGBA")) px = a[a[:, :, 3] > 128][:, :3].astype(np.int32) if px.size == 0: return (120, 120, 120) q = px // 24 * 24 key = q[:, 0] * 65536 + q[:, 1] * 256 + q[:, 2] counts = np.bincount(key) order = np.argsort(-counts)[:5] sel = np.isin(key, np.unique(key)[order]) col = q[sel].mean(axis=0) return tuple(int(v) for v in col) except Exception: return (120, 120, 120) def _draw_color_label(draw, font, text, x, y, text_color, dot=None, align="left"): """ 绘制 “● 颜色名” 标签。 x, y 为文本视觉左上角;align="center" 时 x 为整体(圆点+文本)的水平中心。 dot 非 None(颜色元组)时在文本前画小圆点。 """ tb = draw.textbbox((0, 0), text, font=font) tw, th = tb[2] - tb[0], tb[3] - tb[1] if dot is not None: dot_d = max(6, int(th * 0.55)) unit_w = dot_d + 4 + tw if align == "center": x = int(x - unit_w / 2) cy = y + th // 2 draw.ellipse([x, cy - dot_d // 2, x + dot_d, cy + dot_d // 2], fill=tuple(dot)) x += dot_d + 4 draw.text((x - tb[0], y - tb[1]), text, font=font, fill=tuple(text_color)) def _paste_alpha(canvas, im, x, y): """按 alpha 通道粘贴""" im = im.convert("RGBA") canvas.paste(im, (int(x), int(y)), im.getchannel("A")) def _trim_alpha(im, threshold=8): """裁切透明底图像的最小边(alpha>threshold 的包围盒),无透明通道则原样返回""" if im.mode != "RGBA": return im a = np.asarray(im)[:, :, 3] ys, xs = np.where(a > threshold) if xs.size == 0: return im return im.crop((int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1)) def upload_image_by_io(image_pil: Image) -> str: # post_headers = {"Authorization": settings.Authorization} im = image_pil # 带透明通道(RGBA)时存 PNG 以保留透明;否则存 JPEG has_alpha = im.mode in ("RGBA", "LA", "PA") fmt, ext, ctype = ("PNG", ".png", "image/png") if has_alpha else ("JPEG", ".jpg", "image/jpeg") img = BytesIO() try: im.save(img, format=fmt) # format: PNG or JPEG except Exception: img = BytesIO() im.save(img, format="PNG") # 兜底:RGBA 无法存 JPEG 时退化为 PNG ext, ctype = ".png", "image/png" img.seek(0) # rewind to the start try: url = settings.DOMAIN + "/api/upload" resultData = requests.post( url, files={"file": ("image" + ext, img, ctype)}, timeout=100 ).json() return resultData["data"]["url"] except Exception as e: print("upload_image_by_io error:", e) return None # ---------------------------------------------------------------- 主函数 -- def compose_shoe_main_image( template, logo=None, data=None, shoes=None, info_lines=None, output_path=None, upload=False, # True=合成后上传到 OSS(upload_image_by_io)并返回链接地址 font_path=None, # ---- 浅色区域检测 ---- band_color=None, # None=自动从模板左上角检测;可显式传 (r, g, b) band_tolerance=30, # 浅色判定容差(最大通道差) # ---- A区域:logo ---- logo_top=8, # 顶部固定位置 y logo_left=8, # 左侧位置 x logo_width_ratio=0.95, # logo 最大宽度 = 浅色区域宽度(该位置) * 比例 logo_width=None, # 指定目标宽度(仍受浅色区域宽度约束) # ---- B区域:鞋款色卡列 ---- shoes_top=None, # None=logo 下方 shoes_bottom=None, # None=信息条上方 cell_pad=4, # 每格上下留白 shoe_width_limit_ratio=1.15, # 鞋子最大宽度 = 该格浅色区域宽度 * 比例(可略溢出) shoe_scale=1.0, # 鞋子最终尺寸缩放系数:1.0=自动适配大小,>1 放大,<1 缩小 label_pos="bottom", # 颜色文本位置:"bottom"下方 | "right"右侧(图1样式) label_font_size=15, label_color=(60, 60, 60), label_dot=True, # 颜色文本前是否画小圆点(取鞋子主色) label_gap=5, # 鞋与颜色文本的间距 shoe_center_offset=0, # 鞋子中心相对浅色区域中心的水平偏移 # ---- C区域:底部文本信息 ---- info_bar=True, # True=画圆角信息条;False=只画文字 info_bar_color=(112, 175, 217), info_text_color=(255, 255, 255), # 条内文字色 info_plain_text_color=(80, 80, 80), # info_bar=False 时的文字色 info_bar_height=None, # 信息条总高度;None=由 info_bar_pad_y 决定(文本高 + 上下间距*2) info_bar_pad_y=5, # 信息条内文字的上下间距(上下内边距,可调节) info_bar_gap=7, info_bar_width=None, # None=每条按自身文本自适应宽度 info_left=0, info_bottom_margin=16, info_font_size=14, info_pad_x=12, # ---- 调试 ---- debug=False, # 在输出旁另存 *_debug.png(浅色右缘/各区域框) ): """ 鞋类主图合成。 参数: template 底图(图2)路径或 PIL.Image logo A区域 logo 路径或 PIL.Image(建议透明 PNG),None=不放 data 合并入参(推荐):一个字典,同时包含 logo、底部信息和鞋款列表,如 {"logo": "logo.png", "number_mid": {"name": "货号(中童)", "value": "1025B-17"}, "size_mid": {"name": "尺码 Size", "value": "24-29#"}, "number_big": {"name": "货号(大童)", "value": "1025A-17"}, "size_big": {"name": "尺码 Size", "value": "30-35#"}, "box": {"name": "装箱 Box", "value": "60双"}, "images": [{"image_path": "鞋1.png", "color": "米白"}, ...]} logo 取自 data["logo"](未直接传 logo 时);B区域取 data["images"] 每个 元素的 image_path/color;C区域按 number_mid/size_mid/number_big/size_big/box 顺序取字段,缺省字段自动跳过。 logo 与 image_path 均支持 路径 或 PIL.Image;传 PIL 时直接使用该对象 (内部先转 RGBA 再裁切/缩放,不会改动传入的对象本身)。 shoes B区域鞋款列表(data 未提供时的独立入参),元素为 {"image": 路径/PIL.Image, "color": "米白"};数量不固定 info_lines C区域文本(data 未提供时的独立入参),元素为 (标签, 值) 或纯字符串,如 [("货号(中童)", "1025B-17"), ("装箱 Box", "60双")] output_path 输出路径(.jpg/.png),None=不保存 upload True=合成后调用 upload_image_by_io 上传到 OSS,并返回链接地址 其余参数见函数签名默认值。 返回:upload=True 时为上传后的链接地址(str,上传失败为 None); 否则为合成结果 PIL.Image (RGB) """ shoes = shoes or [] info_lines = info_lines or [] # ---------- 合并入参 data -> logo + shoes + info_lines ---------- # data: {logo, number_mid, size_mid, number_big, size_big, box, images:[{image_path,color},...]} if data: if logo is None: logo = data.get("logo") if not shoes: shoes = [{"image": im.get("image_path"), "color": im.get("color", "")} for im in data.get("images", [])] if not info_lines: for key in ("number_mid", "size_mid", "number_big", "size_big", "box"): fv = data.get(key) if fv: info_lines.append((str(fv.get("name", "")), str(fv.get("value", "")))) # ---------- 底图(保留透明通道:模板右侧为透明区,输出需为 RGBA) ---------- canvas = _to_image(template).convert("RGBA") W, H = canvas.size draw = ImageDraw.Draw(canvas) # ---------- 浅色区域检测 ---------- band_width, _band_c = _detect_band_widths(canvas, band_color=band_color, tolerance=band_tolerance) debug_boxes = [] # ---------- A区域:logo(宽度受浅色区域约束) ---------- def _band_min(y0, y1): y0, y1 = max(0, int(y0)), min(H, int(y1)) if y1 <= y0: y1 = y0 + 1 return float(band_width[y0:y1].min()) logo_bottom = int(logo_top) + 10 if logo is not None: logo_im = _trim_alpha(_to_image(logo).convert("RGBA")) lw0, lh0 = logo_im.size # 第一遍:按预估高度求该位置浅色区域最小宽度 target_w = min(logo_width if logo_width else 1e18, _band_min(logo_top, logo_top + max(20, lh0)) * logo_width_ratio) scale = target_w / lw0 if logo_width or scale < 1: logo_im = logo_im.resize((max(1, int(lw0 * scale)), max(1, int(lh0 * scale))), Image.LANCZOS) # 第二遍:按实际高度复核,防止 logo 过高时压过浅色区域 lw1, lh1 = logo_im.size limit2 = _band_min(logo_top, logo_top + lh1) * logo_width_ratio if lw1 > limit2 and limit2 > 4: s2 = limit2 / lw1 logo_im = logo_im.resize((max(1, int(lw1 * s2)), max(1, int(lh1 * s2))), Image.LANCZOS) lw1, lh1 = logo_im.size _paste_alpha(canvas, logo_im, logo_left, logo_top) logo_bottom = int(logo_top) + lh1 debug_boxes.append((logo_left, logo_top, logo_left + lw1, logo_bottom, (255, 0, 255))) # ---------- C区域:底部信息条(先算位置,决定 B 区域下界) ---------- info_top = H if info_lines: rows = [] for line in info_lines: if isinstance(line, (tuple, list)): lab = str(line[0]) if len(line) > 0 else "" val = str(line[1]) if len(line) > 1 else "" else: lab, val = str(line), "" rows.append((lab, val)) info_font = _load_font(font_path, info_font_size) def _tw(t): if not t: return 0 b = draw.textbbox((0, 0), t, font=info_font) return b[2] - b[0] bb = draw.textbbox((0, 0), "国A", font=info_font) th = bb[3] - bb[1] # 信息条高度:显式 info_bar_height 优先;否则 = 文本高 + 上下间距*2 bar_h = int(info_bar_height) if info_bar_height else th + 2 * int(info_bar_pad_y) row_widths = [] for lab, val in rows: w = _tw(lab) + (8 + _tw(val) if val else 0) row_widths.append(w + 2 * int(info_pad_x)) if info_bar_width: row_widths = [int(info_bar_width)] * len(rows) block_h = len(rows) * bar_h + (len(rows) - 1) * int(info_bar_gap) info_top = H - int(info_bottom_margin) - block_h y = info_top for (lab, val), bw_row in zip(rows, row_widths): if info_bar: # 左侧直角、右侧圆角:左段矩形 + 右端半圆 x0, x1 = int(info_left), int(info_left + bw_row) r = min(bar_h // 2, (x1 - x0) // 2) draw.rectangle([x0, y, x1 - r, y + bar_h], fill=tuple(info_bar_color)) draw.ellipse([x1 - 2 * r, y, x1, y + bar_h], fill=tuple(info_bar_color)) tc = tuple(info_text_color) if info_bar else tuple(info_plain_text_color) ty = y + (bar_h - th) // 2 - bb[1] if lab: draw.text((int(info_left + info_pad_x), ty), lab, font=info_font, fill=tc) if val: vx = int(info_left + bw_row - info_pad_x - _tw(val)) draw.text((vx, ty), val, font=info_font, fill=tc) debug_boxes.append((info_left, y, info_left + bw_row, y + bar_h, (0, 128, 255))) y += bar_h + int(info_bar_gap) # ---------- B区域:鞋款色卡列(按数量垂直等分) ---------- n = len(shoes) if n: area_top = int(shoes_top) if shoes_top is not None else logo_bottom + 12 area_bottom = (int(shoes_bottom) if shoes_bottom is not None else (info_top - 12 if info_lines else H - 10)) if area_bottom - area_top < 30: raise ValueError( "B区域可用高度不足(area_top={}, area_bottom={}),请调整 shoes_top/shoes_bottom 或减少鞋款数量" .format(area_top, area_bottom)) cell_h = (area_bottom - area_top) / n lab_font = _load_font(font_path, label_font_size) for i, item in enumerate(shoes): if isinstance(item, str): shoe_src, color_name = item, "" else: shoe_src = item.get("image") color_name = str(item.get("color", "")) shoe_im = _trim_alpha(_to_image(shoe_src).convert("RGBA")) sw0, sh0 = shoe_im.size cell_y0 = area_top + i * cell_h cell_cy = int(min(cell_y0 + cell_h / 2, H - 1)) bw = float(band_width[cell_cy]) # 该格中心处浅色区域宽度 margin = max(3, int(bw * 0.03)) # 距浅色区左右边缘的安全留白 # 颜色文本尺寸(圆点 + 4px + 文本,与 _draw_color_label 保持一致) if color_name: lb = draw.textbbox((0, 0), color_name, font=lab_font) label_h = lb[3] - lb[1] label_tw = lb[2] - lb[0] dot_d = max(6, int(label_h * 0.55)) if label_dot else 0 label_w = dot_d + 4 + label_tw else: label_h = label_tw = label_w = 0 # 该格内鞋子可占用的最大盒子:已扣除留白与颜色文本, # 保证"鞋 + 颜色名称"整体都落在浅色区内、绝不超出。 if label_pos == "bottom": box_w = bw - 2 * margin box_h = cell_h - 2 * int(cell_pad) - (label_h + int(label_gap) if color_name else 0) else: # right:鞋在左、文本紧跟其右,两者都要在带内 box_w = bw - 2 * margin - (label_w + int(label_gap) if color_name else 0) box_h = cell_h - 2 * int(cell_pad) box_w = max(10.0, min(box_w, bw * float(shoe_width_limit_ratio))) fit_scale = min(box_w / sw0, box_h / sh0) scale = fit_scale * float(shoe_scale) sw, sh = max(2, int(sw0 * scale)), max(2, int(sh0 * scale)) if sw > box_w or sh > box_h: # shoe_scale>1 时仍限制在盒内 sw, sh = max(2, int(sw0 * fit_scale)), max(2, int(sh0 * fit_scale)) if (sw, sh) != (sw0, sh0): shoe_im = shoe_im.resize((sw, sh), Image.LANCZOS) if label_pos == "bottom": sx = int(max(margin, (bw - sw) / 2 + float(shoe_center_offset))) block_h = sh + (int(label_gap) + label_h if color_name else 0) sy = int(cell_y0 + max(0, (cell_h - block_h) / 2)) else: # right:鞋贴左缘,文本紧跟鞋右、且不超过浅色区右缘 sx = int(margin) sy = int(cell_y0 + max(0, (cell_h - sh) / 2)) label_x = int(sx + sw + int(label_gap)) _paste_alpha(canvas, shoe_im, sx, sy) if debug: debug_boxes.append((sx, sy, sx + sw, sy + sh, (0, 255, 0))) if color_name: dot_color = _dominant_color(shoe_im) if label_dot else None if label_pos == "bottom": ly = sy + sh + int(label_gap) _draw_color_label(draw, lab_font, color_name, int(sx + sw / 2), ly, label_color, dot=dot_color, align="center") else: ly = sy + (sh - label_h) // 2 _draw_color_label(draw, lab_font, color_name, label_x, ly, label_color, dot=dot_color, align="left") # ---------- 调试叠加图 ---------- if debug and output_path: dbg = canvas.copy() d = ImageDraw.Draw(dbg) pts = [(int(band_width[y]), y) for y in range(0, H, 8)] d.line(pts, fill=(255, 0, 0), width=1) for x0, y0, x1, y1, c in debug_boxes: d.rectangle([int(x0), int(y0), int(x1), int(y1)], outline=c, width=2) base, ext = os.path.splitext(output_path) dbg.convert("RGB").save(base + "_debug" + (ext or ".png")) # ---------- 保存 / 上传 / 返回 ---------- # if output_path: # _save(canvas, output_path) if upload: return upload_image_by_io(canvas) return canvas