shoe_main_image_compose.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  1. # -*- coding: utf-8 -*-
  2. """
  3. 鞋类主图合成
  4. 基于底图模板(如“粉色布景 + 左侧浅色弧形区域”的底图,即需求中的图2),合成三块区域:
  5. A区域:顶部 logo —— 固定顶部位置,宽度不超过底图浅色区域在该位置的宽度
  6. B区域:鞋款色卡列 —— 不同款色的鞋子(数量不固定:1款、5款、更多均可),
  7. 在垂直方向按数量等分放置,每只鞋下方(或右侧)放置该款鞋的颜色文本
  8. C区域:最底部文本信息 —— 圆角信息条(货号/尺码/装箱等)
  9. 浅色区域宽度由本模块自动检测(对模板左侧浅色像素逐行求右缘),无需手工量坐标。
  10. 仅依赖 Pillow + numpy。logo、鞋子图建议使用带透明通道的 PNG(合成前自动裁切到 alpha 最小边)。
  11. 用法示例:
  12. from shoe_main_image_compose import compose_shoe_main_image
  13. compose_shoe_main_image(
  14. template=r"底图.png",
  15. logo=r"logo.png",
  16. shoes=[
  17. {"image": r"鞋_米白.png", "color": "米白"},
  18. {"image": r"鞋_粉色.png", "color": "粉色"},
  19. {"image": r"鞋_蓝色.png", "color": "蓝色"},
  20. ],
  21. info_lines=[
  22. ("货号(中童)", "1025B-17"),
  23. ("尺码 Size", "24-29#"),
  24. ("装箱 Box", "60双"),
  25. ],
  26. output_path=r"output.png",
  27. )
  28. """
  29. import os, settings, requests
  30. from io import BytesIO
  31. import numpy as np
  32. from PIL import Image, ImageDraw, ImageFont
  33. # ---------------------------------------------------------------- 基础工具 --
  34. def _to_image(im_or_path):
  35. """path 或 PIL.Image -> PIL.Image"""
  36. if isinstance(im_or_path, str):
  37. return Image.open(im_or_path)
  38. return im_or_path
  39. def _load_font(font_path, size):
  40. """加载中文字体,未指定时按 项目resources -> 系统字体 顺序回退"""
  41. candidates = []
  42. if font_path:
  43. candidates.append(font_path)
  44. here = os.path.dirname(os.path.abspath(__file__))
  45. candidates += [
  46. os.path.join(here, "..", "resources", "ttf", "simhei.ttf"),
  47. os.path.join("resources", "ttf", "simhei.ttf"),
  48. r"C:\Windows\Fonts\simhei.ttf",
  49. r"C:\Windows\Fonts\msyh.ttc",
  50. ]
  51. for p in candidates:
  52. if p and os.path.exists(p):
  53. try:
  54. return ImageFont.truetype(p, size)
  55. except Exception:
  56. continue
  57. return ImageFont.load_default()
  58. def _save(canvas, path):
  59. d = os.path.dirname(os.path.abspath(path))
  60. if d:
  61. os.makedirs(d, exist_ok=True)
  62. ext = os.path.splitext(path)[1].lower()
  63. if ext in (".jpg", ".jpeg"):
  64. canvas.convert("RGB").save(path, format="JPEG", quality=92)
  65. else:
  66. canvas.save(path, format="PNG") # 保留 RGBA 透明通道
  67. def _detect_band_widths(canvas, band_color=None, tolerance=30):
  68. """
  69. 检测底图左侧浅色区域(如奶油色弧带)的逐行右缘。
  70. 返回 (widths, band_color):
  71. widths np.array, len=H, widths[y] = 第 y 行浅色区域的右缘 x
  72. band_color 实际使用的浅色基准色 (r, g, b)
  73. band_color 为 None 时,从模板左上角采样取中值作为基准色。
  74. """
  75. a = np.asarray(canvas)
  76. rgb = a[:, :, :3].astype(float) # 只用 RGB 通道做颜色判定
  77. alpha = a[:, :, 3] if a.shape[2] >= 4 else None # 有透明通道时排除透明像素
  78. H, W = rgb.shape[:2]
  79. if band_color is None:
  80. sample = rgb[4:64, 4:44, :].reshape(-1, 3)
  81. bc = np.median(sample, axis=0)
  82. else:
  83. bc = np.asarray(band_color, dtype=float)
  84. # 双条件匹配:颜色距离 + 色度差(通道极差 max-min)。
  85. # 单靠颜色距离时,比奶油色"更白"的纯白背景(距离 < tolerance)会被误判成浅色区,
  86. # 导致带宽被高估到 W*0.6 上限。奶油色带暖调(极差≈24),纯白≈0、深藏青≈15,
  87. # 用 |像素极差 - 基准极差| < cf_tol 即可排除白底,同时兼容 奶油→藏青 等其它配色。
  88. dist = np.abs(rgb - bc).max(axis=2)
  89. band_cf = float(bc.max() - bc.min())
  90. cf = rgb.max(axis=2) - rgb.min(axis=2)
  91. cf_tol = max(6.0, band_cf * 0.5)
  92. match = (dist < tolerance) & (np.abs(cf - band_cf) < cf_tol)
  93. if alpha is not None:
  94. match = match & (alpha > 128) # 透明区(模板右侧)不算浅色区
  95. match = match.astype(np.int8)
  96. win = 21
  97. kernel = np.ones(win)
  98. limit = int(W * 0.6) # 浅色区域不会超过图宽的 60%
  99. widths = np.zeros(H, dtype=int)
  100. for y in range(H):
  101. c = np.convolve(match[y], kernel, mode="same")
  102. cands = np.where(c >= win * 0.6)[0]
  103. cands = cands[cands < limit]
  104. widths[y] = int(cands.max()) if cands.size else 0
  105. # 中值平滑,消除单行噪声
  106. sm = np.zeros_like(widths)
  107. for y in range(H):
  108. lo, hi = max(0, y - 6), min(H, y + 7)
  109. sm[y] = int(np.median(widths[lo:hi]))
  110. widths = sm
  111. # 兜底:没检测到浅色区域时按图宽 32% 处理
  112. if int(widths.max()) < 40:
  113. widths = np.full(H, int(W * 0.32), dtype=int)
  114. return widths, tuple(int(v) for v in bc)
  115. def _dominant_color(im):
  116. """取带透明通道图片的主色(用于颜色文本前的圆点)"""
  117. try:
  118. a = np.asarray(im.convert("RGBA"))
  119. px = a[a[:, :, 3] > 128][:, :3].astype(np.int32)
  120. if px.size == 0:
  121. return (120, 120, 120)
  122. q = px // 24 * 24
  123. key = q[:, 0] * 65536 + q[:, 1] * 256 + q[:, 2]
  124. counts = np.bincount(key)
  125. order = np.argsort(-counts)[:5]
  126. sel = np.isin(key, np.unique(key)[order])
  127. col = q[sel].mean(axis=0)
  128. return tuple(int(v) for v in col)
  129. except Exception:
  130. return (120, 120, 120)
  131. def _draw_color_label(draw, font, text, x, y, text_color, dot=None, align="left"):
  132. """
  133. 绘制 “● 颜色名” 标签。
  134. x, y 为文本视觉左上角;align="center" 时 x 为整体(圆点+文本)的水平中心。
  135. dot 非 None(颜色元组)时在文本前画小圆点。
  136. """
  137. tb = draw.textbbox((0, 0), text, font=font)
  138. tw, th = tb[2] - tb[0], tb[3] - tb[1]
  139. if dot is not None:
  140. dot_d = max(6, int(th * 0.55))
  141. unit_w = dot_d + 4 + tw
  142. if align == "center":
  143. x = int(x - unit_w / 2)
  144. cy = y + th // 2
  145. draw.ellipse([x, cy - dot_d // 2, x + dot_d, cy + dot_d // 2], fill=tuple(dot))
  146. x += dot_d + 4
  147. draw.text((x - tb[0], y - tb[1]), text, font=font, fill=tuple(text_color))
  148. def _paste_alpha(canvas, im, x, y):
  149. """按 alpha 通道粘贴"""
  150. im = im.convert("RGBA")
  151. canvas.paste(im, (int(x), int(y)), im.getchannel("A"))
  152. def _trim_alpha(im, threshold=8):
  153. """裁切透明底图像的最小边(alpha>threshold 的包围盒),无透明通道则原样返回"""
  154. if im.mode != "RGBA":
  155. return im
  156. a = np.asarray(im)[:, :, 3]
  157. ys, xs = np.where(a > threshold)
  158. if xs.size == 0:
  159. return im
  160. return im.crop((int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1))
  161. def upload_image_by_io(image_pil: Image) -> str:
  162. # post_headers = {"Authorization": settings.Authorization}
  163. im = image_pil
  164. # 带透明通道(RGBA)时存 PNG 以保留透明;否则存 JPEG
  165. has_alpha = im.mode in ("RGBA", "LA", "PA")
  166. fmt, ext, ctype = ("PNG", ".png", "image/png") if has_alpha else ("JPEG", ".jpg", "image/jpeg")
  167. img = BytesIO()
  168. try:
  169. im.save(img, format=fmt) # format: PNG or JPEG
  170. except Exception:
  171. img = BytesIO()
  172. im.save(img, format="PNG") # 兜底:RGBA 无法存 JPEG 时退化为 PNG
  173. ext, ctype = ".png", "image/png"
  174. img.seek(0) # rewind to the start
  175. try:
  176. url = settings.DOMAIN + "/api/upload"
  177. resultData = requests.post(
  178. url, files={"file": ("image" + ext, img, ctype)},
  179. timeout=100
  180. ).json()
  181. return resultData["data"]["url"]
  182. except Exception as e:
  183. print("upload_image_by_io error:", e)
  184. return None
  185. # ---------------------------------------------------------------- 主函数 --
  186. def compose_shoe_main_image(
  187. template,
  188. logo=None,
  189. data=None,
  190. shoes=None,
  191. info_lines=None,
  192. output_path=None,
  193. upload=False, # True=合成后上传到 OSS(upload_image_by_io)并返回链接地址
  194. font_path=None,
  195. # ---- 浅色区域检测 ----
  196. band_color=None, # None=自动从模板左上角检测;可显式传 (r, g, b)
  197. band_tolerance=30, # 浅色判定容差(最大通道差)
  198. # ---- A区域:logo ----
  199. logo_top=8, # 顶部固定位置 y
  200. logo_left=8, # 左侧位置 x
  201. logo_width_ratio=0.95, # logo 最大宽度 = 浅色区域宽度(该位置) * 比例
  202. logo_width=None, # 指定目标宽度(仍受浅色区域宽度约束)
  203. # ---- B区域:鞋款色卡列 ----
  204. shoes_top=None, # None=logo 下方
  205. shoes_bottom=None, # None=信息条上方
  206. cell_pad=4, # 每格上下留白
  207. shoe_width_limit_ratio=1.15, # 鞋子最大宽度 = 该格浅色区域宽度 * 比例(可略溢出)
  208. shoe_scale=1.0, # 鞋子最终尺寸缩放系数:1.0=自动适配大小,>1 放大,<1 缩小
  209. # 颜色文本位置:下方组 bottom(居中)/bottom-left(左对齐)/bottom-right(右对齐);
  210. # 右侧组 right(居中)/right-top(顶对齐)/right-bottom(底对齐)
  211. label_pos="bottom",
  212. label_font_size=15,
  213. label_color=(60, 60, 60),
  214. label_dot=True, # 颜色文本前是否画小圆点(取鞋子主色)
  215. label_gap=5, # 鞋与颜色文本的间距
  216. label_offset=(0, 0), # 颜色文本偏移量 (dx, dy):dx 左右(>0 右移),dy 上下(>0 下移)
  217. shoe_center_offset=0, # 鞋子中心相对浅色区域中心的水平偏移
  218. # ---- C区域:底部文本信息 ----
  219. info_bar=True, # True=画圆角信息条;False=只画文字
  220. info_bar_color=(112, 175, 217),
  221. info_text_color=(255, 255, 255), # 条内文字色
  222. info_plain_text_color=(80, 80, 80), # info_bar=False 时的文字色
  223. info_bar_height=None, # 信息条总高度;None=由 info_bar_pad_y 决定(文本高 + 上下间距*2)
  224. info_bar_pad_y=5, # 信息条内文字的上下间距(上下内边距,可调节)
  225. info_bar_gap=7,
  226. info_bar_width=None, # None=每条按自身文本自适应宽度
  227. info_left=0,
  228. info_bottom_margin=16,
  229. info_font_size=14,
  230. info_pad_x=12,
  231. # ---- 调试 ----
  232. debug=False, # 在输出旁另存 *_debug.png(浅色右缘/各区域框)
  233. ):
  234. """
  235. 鞋类主图合成。
  236. 参数:
  237. template 底图(图2)路径或 PIL.Image
  238. logo A区域 logo 路径或 PIL.Image(建议透明 PNG),None=不放
  239. data 合并入参(推荐):一个字典,同时包含 logo、底部信息和鞋款列表,如
  240. {"logo": "logo.png",
  241. "number_mid": {"name": "货号(中童)", "value": "1025B-17"},
  242. "size_mid": {"name": "尺码 Size", "value": "24-29#"},
  243. "number_big": {"name": "货号(大童)", "value": "1025A-17"},
  244. "size_big": {"name": "尺码 Size", "value": "30-35#"},
  245. "box": {"name": "装箱 Box", "value": "60双"},
  246. "images": [{"image_path": "鞋1.png", "color": "米白"}, ...]}
  247. logo 取自 data["logo"](未直接传 logo 时);B区域取 data["images"] 每个
  248. 元素的 image_path/color;C区域按 number_mid/size_mid/number_big/size_big/box
  249. 顺序取字段,缺省字段自动跳过。
  250. logo 与 image_path 均支持 路径 或 PIL.Image;传 PIL 时直接使用该对象
  251. (内部先转 RGBA 再裁切/缩放,不会改动传入的对象本身)。
  252. shoes B区域鞋款列表(data 未提供时的独立入参),元素为
  253. {"image": 路径/PIL.Image, "color": "米白"};数量不固定
  254. info_lines C区域文本(data 未提供时的独立入参),元素为 (标签, 值) 或纯字符串,如
  255. [("货号(中童)", "1025B-17"), ("装箱 Box", "60双")]
  256. output_path 输出路径(.jpg/.png),None=不保存
  257. upload True=合成后调用 upload_image_by_io 上传到 OSS,并返回链接地址
  258. 其余参数见函数签名默认值。
  259. 返回:upload=True 时为上传后的链接地址(str,上传失败为 None);
  260. 否则为合成结果 PIL.Image (RGB)
  261. """
  262. shoes = shoes or []
  263. info_lines = info_lines or []
  264. # ---------- 合并入参 data -> logo + shoes + info_lines ----------
  265. # data: {logo, number_mid, size_mid, number_big, size_big, box, images:[{image_path,color},...]}
  266. if data:
  267. if logo is None:
  268. logo = data.get("logo")
  269. if not shoes:
  270. shoes = [{"image": im.get("image_path"), "color": im.get("color", "")}
  271. for im in data.get("images", [])]
  272. if not info_lines:
  273. for key in ("number_mid", "size_mid", "number_big", "size_big", "box"):
  274. fv = data.get(key)
  275. if fv:
  276. info_lines.append((str(fv.get("name", "")), str(fv.get("value", ""))))
  277. # ---------- 底图(保留透明通道:模板右侧为透明区,输出需为 RGBA) ----------
  278. canvas = _to_image(template).convert("RGBA")
  279. W, H = canvas.size
  280. draw = ImageDraw.Draw(canvas)
  281. # ---------- 浅色区域检测 ----------
  282. band_width, _band_c = _detect_band_widths(canvas, band_color=band_color,
  283. tolerance=band_tolerance)
  284. debug_boxes = []
  285. # ---------- A区域:logo(宽度受浅色区域约束) ----------
  286. def _band_min(y0, y1):
  287. y0, y1 = max(0, int(y0)), min(H, int(y1))
  288. if y1 <= y0:
  289. y1 = y0 + 1
  290. return float(band_width[y0:y1].min())
  291. logo_bottom = int(logo_top) + 10
  292. if logo is not None:
  293. logo_im = _trim_alpha(_to_image(logo).convert("RGBA"))
  294. lw0, lh0 = logo_im.size
  295. # 第一遍:按预估高度求该位置浅色区域最小宽度
  296. target_w = min(logo_width if logo_width else 1e18,
  297. _band_min(logo_top, logo_top + max(20, lh0)) * logo_width_ratio)
  298. scale = target_w / lw0
  299. if logo_width or scale < 1:
  300. logo_im = logo_im.resize((max(1, int(lw0 * scale)), max(1, int(lh0 * scale))),
  301. Image.LANCZOS)
  302. # 第二遍:按实际高度复核,防止 logo 过高时压过浅色区域
  303. lw1, lh1 = logo_im.size
  304. limit2 = _band_min(logo_top, logo_top + lh1) * logo_width_ratio
  305. if lw1 > limit2 and limit2 > 4:
  306. s2 = limit2 / lw1
  307. logo_im = logo_im.resize((max(1, int(lw1 * s2)), max(1, int(lh1 * s2))),
  308. Image.LANCZOS)
  309. lw1, lh1 = logo_im.size
  310. _paste_alpha(canvas, logo_im, logo_left, logo_top)
  311. logo_bottom = int(logo_top) + lh1
  312. debug_boxes.append((logo_left, logo_top, logo_left + lw1, logo_bottom, (255, 0, 255)))
  313. # ---------- C区域:底部信息条(先算位置,决定 B 区域下界) ----------
  314. info_top = H
  315. if info_lines:
  316. rows = []
  317. for line in info_lines:
  318. if isinstance(line, (tuple, list)):
  319. lab = str(line[0]) if len(line) > 0 else ""
  320. val = str(line[1]) if len(line) > 1 else ""
  321. else:
  322. lab, val = str(line), ""
  323. rows.append((lab, val))
  324. info_font = _load_font(font_path, info_font_size)
  325. def _tw(t):
  326. if not t:
  327. return 0
  328. b = draw.textbbox((0, 0), t, font=info_font)
  329. return b[2] - b[0]
  330. bb = draw.textbbox((0, 0), "国A", font=info_font)
  331. th = bb[3] - bb[1]
  332. # 信息条高度:显式 info_bar_height 优先;否则 = 文本高 + 上下间距*2
  333. bar_h = int(info_bar_height) if info_bar_height else th + 2 * int(info_bar_pad_y)
  334. row_widths = []
  335. for lab, val in rows:
  336. w = _tw(lab) + (8 + _tw(val) if val else 0)
  337. row_widths.append(w + 2 * int(info_pad_x))
  338. if info_bar_width:
  339. row_widths = [int(info_bar_width)] * len(rows)
  340. block_h = len(rows) * bar_h + (len(rows) - 1) * int(info_bar_gap)
  341. info_top = H - int(info_bottom_margin) - block_h
  342. y = info_top
  343. for (lab, val), bw_row in zip(rows, row_widths):
  344. if info_bar:
  345. # 左侧直角、右侧圆角:左段矩形 + 右端半圆
  346. x0, x1 = int(info_left), int(info_left + bw_row)
  347. r = min(bar_h // 2, (x1 - x0) // 2)
  348. draw.rectangle([x0, y, x1 - r, y + bar_h], fill=tuple(info_bar_color))
  349. draw.ellipse([x1 - 2 * r, y, x1, y + bar_h], fill=tuple(info_bar_color))
  350. tc = tuple(info_text_color) if info_bar else tuple(info_plain_text_color)
  351. ty = y + (bar_h - th) // 2 - bb[1]
  352. if lab:
  353. draw.text((int(info_left + info_pad_x), ty), lab,
  354. font=info_font, fill=tc)
  355. if val:
  356. vx = int(info_left + bw_row - info_pad_x - _tw(val))
  357. draw.text((vx, ty), val, font=info_font, fill=tc)
  358. debug_boxes.append((info_left, y, info_left + bw_row, y + bar_h, (0, 128, 255)))
  359. y += bar_h + int(info_bar_gap)
  360. # ---------- B区域:鞋款色卡列(按数量垂直等分) ----------
  361. n = len(shoes)
  362. if n:
  363. area_top = int(shoes_top) if shoes_top is not None else logo_bottom + 12
  364. area_bottom = (int(shoes_bottom) if shoes_bottom is not None
  365. else (info_top - 12 if info_lines else H - 10))
  366. if area_bottom - area_top < 30:
  367. raise ValueError(
  368. "B区域可用高度不足(area_top={}, area_bottom={}),请调整 shoes_top/shoes_bottom 或减少鞋款数量"
  369. .format(area_top, area_bottom))
  370. cell_h = (area_bottom - area_top) / n
  371. lab_font = _load_font(font_path, label_font_size)
  372. # 颜色文本偏移量 (dx, dy):dx 左右(>0 右移),dy 上下(>0 下移)
  373. try:
  374. label_dx, label_dy = int(label_offset[0]), int(label_offset[1])
  375. except (TypeError, ValueError, IndexError):
  376. label_dx, label_dy = 0, 0
  377. # 文本位置分组:below 组在鞋下方(bottom/bottom-left/bottom-right),其余在鞋右侧
  378. below = label_pos in ("bottom", "bottom-left", "bottom-right")
  379. for i, item in enumerate(shoes):
  380. if isinstance(item, str):
  381. shoe_src, color_name = item, ""
  382. else:
  383. shoe_src = item.get("image")
  384. color_name = str(item.get("color", ""))
  385. # print("shoe_src", shoe_src)
  386. shoe_im = _trim_alpha(_to_image(shoe_src).convert("RGBA"))
  387. sw0, sh0 = shoe_im.size
  388. cell_y0 = area_top + i * cell_h
  389. cell_cy = int(min(cell_y0 + cell_h / 2, H - 1))
  390. bw = float(band_width[cell_cy]) # 该格中心处浅色区域宽度
  391. margin = max(3, int(bw * 0.03)) # 距浅色区左右边缘的安全留白
  392. # 颜色文本尺寸(圆点 + 4px + 文本,与 _draw_color_label 保持一致)
  393. if color_name:
  394. lb = draw.textbbox((0, 0), color_name, font=lab_font)
  395. label_h = lb[3] - lb[1]
  396. label_tw = lb[2] - lb[0]
  397. dot_d = max(6, int(label_h * 0.55)) if label_dot else 0
  398. label_w = dot_d + 4 + label_tw
  399. else:
  400. label_h = label_tw = label_w = 0
  401. # 该格内鞋子可占用的最大盒子:已扣除留白与颜色文本,
  402. # 保证"鞋 + 颜色名称"整体都落在浅色区内、绝不超出。
  403. if below:
  404. box_w = bw - 2 * margin
  405. box_h = cell_h - 2 * int(cell_pad) - (label_h + int(label_gap) if color_name else 0)
  406. else: # right:鞋在左、文本紧跟其右,两者都要在带内
  407. box_w = bw - 2 * margin - (label_w + int(label_gap) if color_name else 0)
  408. box_h = cell_h - 2 * int(cell_pad)
  409. box_w = max(10.0, min(box_w, bw * float(shoe_width_limit_ratio)))
  410. fit_scale = min(box_w / sw0, box_h / sh0)
  411. scale = fit_scale * float(shoe_scale)
  412. sw, sh = max(2, int(sw0 * scale)), max(2, int(sh0 * scale))
  413. if sw > box_w or sh > box_h: # shoe_scale>1 时仍限制在盒内
  414. sw, sh = max(2, int(sw0 * fit_scale)), max(2, int(sh0 * fit_scale))
  415. if (sw, sh) != (sw0, sh0):
  416. shoe_im = shoe_im.resize((sw, sh), Image.LANCZOS)
  417. if below:
  418. sx = int(max(margin, (bw - sw) / 2 + float(shoe_center_offset)))
  419. block_h = sh + (int(label_gap) + label_h if color_name else 0)
  420. sy = int(cell_y0 + max(0, (cell_h - block_h) / 2))
  421. else: # right:鞋贴左缘,文本紧跟鞋右、且不超过浅色区右缘
  422. sx = int(margin)
  423. sy = int(cell_y0 + max(0, (cell_h - sh) / 2))
  424. label_x = int(sx + sw + int(label_gap))
  425. _paste_alpha(canvas, shoe_im, sx, sy)
  426. if debug:
  427. debug_boxes.append((sx, sy, sx + sw, sy + sh, (0, 255, 0)))
  428. if color_name:
  429. dot_color = _dominant_color(shoe_im) if label_dot else None
  430. if below:
  431. # 鞋下方:垂直在鞋底+间距;水平按 bottom / bottom-left / bottom-right 对齐
  432. ly = sy + sh + int(label_gap) + label_dy
  433. if label_pos == "bottom-right": # 右对齐:文本右缘贴鞋右缘
  434. lx, align = int(sx + sw - label_w) + label_dx, "left"
  435. elif label_pos == "bottom-left": # 左对齐:文本左缘贴鞋左缘
  436. lx, align = int(sx) + label_dx, "left"
  437. else: # bottom:水平居中
  438. lx, align = int(sx + sw / 2) + label_dx, "center"
  439. _draw_color_label(draw, lab_font, color_name, lx, ly,
  440. label_color, dot=dot_color, align=align)
  441. else:
  442. # 鞋右侧:水平在鞋右+间距;垂直按 right / right-top / right-bottom 对齐
  443. lx = int(label_x) + label_dx
  444. if label_pos == "right-top": # 顶对齐:文本顶贴鞋顶
  445. ly = sy + label_dy
  446. elif label_pos == "right-bottom": # 底对齐:文本底贴鞋底
  447. ly = sy + sh - label_h + label_dy
  448. else: # right:垂直居中
  449. ly = sy + (sh - label_h) // 2 + label_dy
  450. _draw_color_label(draw, lab_font, color_name, lx, ly,
  451. label_color, dot=dot_color, align="left")
  452. # ---------- 调试叠加图 ----------
  453. if debug and output_path:
  454. dbg = canvas.copy()
  455. d = ImageDraw.Draw(dbg)
  456. pts = [(int(band_width[y]), y) for y in range(0, H, 8)]
  457. d.line(pts, fill=(255, 0, 0), width=1)
  458. for x0, y0, x1, y1, c in debug_boxes:
  459. d.rectangle([int(x0), int(y0), int(x1), int(y1)], outline=c, width=2)
  460. base, ext = os.path.splitext(output_path)
  461. dbg.convert("RGB").save(base + "_debug" + (ext or ".png"))
  462. # ---------- 保存 / 上传 / 返回 ----------
  463. # if output_path:
  464. # _save(canvas, output_path)
  465. if upload:
  466. return upload_image_by_io(canvas)
  467. return canvas