Przeglądaj źródła

Merge remote-tracking branch 'origin/zhanjiang_scene' into feature-frontend-0907

panqiuyao 1 miesiąc temu
rodzic
commit
31cccd4d62

+ 87 - 0
python/api.py

@@ -1,3 +1,4 @@
+import uuid
 from re import search, match
 from natsort.natsort import order_by_index, natsorted
 from sqlalchemy import func
@@ -14,6 +15,7 @@ from utils.utils_func import check_path
 from sockets.socket_client import socket_manager
 from mcu.DeviceControl import DeviceControl
 import time, shutil, os
+from io import BytesIO
 from sqlalchemy import and_, asc, desc
 from functools import partial
 from service.deal_image import DealImage
@@ -2321,10 +2323,13 @@ async def upload_item_to_open_api(params: OpenApiUploadItem):
     """将货号800x800目录下已抠图图片上传到开放平台商品档案"""
     goods_art_no = params.goods_art_no
     company_id = params.company_id
+    item_type = params.item_type
     if not goods_art_no:
         raise UnicornException("货号不能为空")
     if not company_id:
         raise UnicornException("企业ID不能为空")
+    if item_type not in (1, 2):
+        raise UnicornException("上传类型item_type必须为1(商品)或2(材料)")
 
     goods_path = check_goods_art_no_cutout_dir(goods_art_no)
     if not goods_path:
@@ -2368,6 +2373,7 @@ async def upload_item_to_open_api(params: OpenApiUploadItem):
                     "ImageType": image_type,
                     "CreateDate": create_date,
                     "CompanyId": company_id,
+                    "ItemType": item_type,
                 }
                 resp = requests.post(
                     f"{OPEN_API_BASE_URL}/camera/items",
@@ -2397,3 +2403,84 @@ async def upload_item_to_open_api(params: OpenApiUploadItem):
         raise UnicornException(f"请求开放平台接口失败: {str(e)}")
     except Exception as e:
         raise UnicornException(f"上传异常: {str(e)}")
+
+
+async def cutOutImage(original_image_path):
+    from service.remove_bg_ali import RemoveUltraBackground
+    try:
+        remove_pic_ins = RemoveUltraBackground()
+        im_pillow = remove_pic_ins.ultra_segment_fast_pil(
+            file_path=original_image_path,
+        )
+    except Exception as e:
+        print("抠图异常", e)
+        raise UnicornException("抠图异常")
+    return im_pillow
+
+
+def uploadImageIM(im) -> str:
+    img_byte_io = BytesIO()
+    # 根据图片模式选择保存格式
+    if im.mode == 'RGBA':
+        im.save(img_byte_io, format='PNG')
+    else:
+        im.save(img_byte_io, format='JPEG')
+    img_byte_io.seek(0)  # 重置指针到开头
+
+    post_headers = {"Authorization": settings.USER_TOKEN}
+    url = settings.DOMAIN + "/api/upload"
+
+    # 使用字节流上传
+    resultData = requests.post(
+        url,
+        files={"file": ("image.jpg", img_byte_io, "image/jpeg")},
+        headers=post_headers
+    ).json()
+
+    return resultData["data"]["url"]
+
+
+@app.post("/shoes_compose", description="拍照机商品资料上传")
+async def shoeCompose(params: ShoesComposes):
+    from service.shoe_main_image_compose import compose_shoe_main_image  # noqa: E402
+    # settings.USER_TOKEN = "60db012c9dd228dd0b08bea6e6fc915d1b495d8f"
+    # token = "Bearer " + settings.USER_TOKEN
+    tpl = "resources/detail_temp/scene/tpl_1.png"  # 图2:底图模板
+    postData = []
+    for itemIdx, item in enumerate(params.data):
+        print("item", item)
+        main_image_path = item.image_path
+        reference_image = item.reference_image
+        logo = item.logo
+        params.data[itemIdx].logo = logo
+        main_image_pil = await cutOutImage(main_image_path)
+        main_image_url = uploadImageIM(main_image_pil) + "?x-oss-process=image/format,jpg"
+        params.data[itemIdx].image_path = main_image_pil
+        for img in item.images:
+            sub_image_path = img.get("image_path")
+            img['image_path'] = await cutOutImage(sub_image_path)
+        result_url = compose_shoe_main_image(template=tpl,
+                                             data=item.__dict__,
+                                             label_pos="right",
+                                             shoe_scale=0.9,
+                                             font_path="resources/ttl/MiSans-Light.ttf",
+                                             upload=True,
+                                             info_bottom_margin=20,
+                                             label_font_size=30,
+                                             info_bar_height=40,
+                                             info_font_size=34)
+        postData.append({"base_image": main_image_url, "reference_image": reference_image, 'past_image': result_url})
+    headers = {
+        "Authorization": f"Bearer {settings.USER_TOKEN}",
+        "Content-Type": "application/json",
+    }
+
+    response = requests.request(
+        "POST",
+        f"{settings.DOMAIN}/api/ai_image/main/auto_photo_scene_images_zj",
+        headers=headers,
+        json={"data": postData},
+    )
+    print("response", response.content)
+    responseData = response.json()
+    return {"code": 0, "msg": "操作成功", "data": responseData.get("data", None)}

+ 1 - 1
python/config.ini

@@ -10,7 +10,7 @@ app_run = api:app
 # 端口号
 port = 7074
 debug = false
-env = prod
+env = dev
 # 线程数
 works = 1
 project = 惠利玛

+ 1 - 1
python/conifg_info.py

@@ -33,7 +33,7 @@ class ConfigManager:
             "precision": _precision,
         }
         if _addr in self.CONFIG_METADATA_BY_ADDR:
-            print("{} 有重复".format(_addr))
+            # print("{} 有重复".format(_addr))
             raise Exception("地址重复")
         self.CONFIG_METADATA_BY_ADDR[_addr] = _key
 

+ 67 - 0
python/demo_shoe_compose.py

@@ -0,0 +1,67 @@
+# -*- coding: utf-8 -*-
+"""
+demo_shoe_compose.py —— shoe_main_image_compose 的演示/验证脚本
+
+1. 按实测裁剪框从 ref_b.png(图1,目标效果图)直接裁出 logo 与 5 只鞋,
+   生成测试素材(python/output/demo_shoe_assets/)。
+   不做抠图 —— 实际使用时传入的鞋子图本身就是透明底 PNG,
+   compose_shoe_main_image 会先按 alpha 裁切最小边再合成。
+2. 以 ref_a.png(图2,底图模板)为底,分别按 5 款 / 1 款 / 7 款 数量
+   调用 compose_shoe_main_image 合成,验证等分布局。
+
+运行: python demo_shoe_compose.py
+"""
+
+from service.shoe_main_image_compose import compose_shoe_main_image  # noqa: E402
+
+REF_TPL = "resources/detail_temp/scene/tpl_1.png"  # 图2:底图模板
+
+
+# 图1 中各素材的裁剪框 (x1, y1, x2, y2),按像素测量的最小边。
+# x2 统一取 136:刚好在浅色区与粉色布景交界处左侧,裁出的素材不含布景条纹。
+
+def main():
+    logo = "output/demo_shoe_assets/logo.png"
+    items = {
+        "logo": logo,
+        "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双"},
+        "image_path": pil,
+        "images": [
+            {
+                "image_path": pil,
+                "color": "米白",
+            },
+            {
+                "image_path": pil,
+                "color": "粉色",
+            },
+            {
+                "image_path": pil,
+                "color": "卡其",
+            },
+            {
+                "image_path": pil,
+                "color": "蓝色",
+            },
+        ]
+    }
+
+    result_url = compose_shoe_main_image(template=REF_TPL,
+                                         data=items,
+                                         label_pos="right",
+                                         shoe_scale=0.9,
+                                         font_path="resources/ttl/MiSans-Light.ttf",
+                                         upload=True,
+                                         info_bottom_margin=20,
+                                         label_font_size=30,
+                                         info_bar_height=40,
+                                         info_font_size=34)
+    print("合成完成,输出:", result_url)
+
+
+if __name__ == "__main__":
+    main()

+ 25 - 13
python/mcu/DeviceControl.py

@@ -182,20 +182,21 @@ class DeviceControl(BaseClass, metaclass=SingletonType):
         if not self.init_state:
             self.sendSocketMessage(code=1, msg="mcu设备未初始化", device_status=4)
             return False
-        # 先收集需要删除的键
-        keys_to_delete = []
-        for item in list(self.config_manager.CONFIG_METADATA.keys()):
+        # 构建返回结果,过滤掉值为空的项,不修改原字典
+        result = {}
+        for item in self.config_manager.CONFIG_METADATA:
             item_value = self.config_manager.get_dynamic_value(self.config_manager.CONFIG_METADATA[item])
+            # time.sleep(0.01)
+            entry = dict(self.config_manager.CONFIG_METADATA[item])
             if not item_value:
-                keys_to_delete.append(item)
-                continue
-            self.config_manager.CONFIG_METADATA[item]['value'] = item_value
-
-        # 遍历结束后再删除
-        for key in keys_to_delete:
-            del self.config_manager.CONFIG_METADATA[key]
+                entry['value'] = None
+            else:
+                entry['value'] = item_value
+            result[item] = entry
 
-        return self.config_manager.CONFIG_METADATA
+        # print("CONFIG_METADATA", self.config_manager.CONFIG_METADATA)
+        # print("result", result)
+        return result
 
     def get_dynamic_config_signle(self, name):
         """
@@ -204,9 +205,20 @@ class DeviceControl(BaseClass, metaclass=SingletonType):
         data = {"addr": name}
         print("参数值打印", data)
         item_value = self.config_manager.get_dynamic_value(data)
-
+        if not item_value:
+            return None
         return item_value
 
+    def one_key_turnoff(self, device_name, flag):
+        # 一键关机
+        if device_name in self.device_name_dict:
+            device_id = self.device_name_dict[device_name]
+            buf = [self.command["mcu_power_off"],
+                   device_id,
+                   1 if flag else 0,
+                   ]
+            self.add_send_data_queue(buf)
+
     def one_key_down(self):
         cmd = [0x55, 0x55, 0x69, 0x96, 0x00, 0x03, 0x00, 0xC8, 0x0B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x01, 0x01,
                0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x78, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
@@ -792,7 +804,7 @@ class DeviceControl(BaseClass, metaclass=SingletonType):
         if not receive_data:
             return False
         command = receive_data[0]
-        print("get_basic_info_mcu", command)
+        logger.info(f"get_basic_info_mcu{command}")
         if command in self.deal_code_func_dict:
             _data = ' '.join([hex(x) for x in receive_data])
             return self.deal_code_func_dict[command](receive_data)

+ 13 - 12
python/mcu/RemoteControlV2.py

@@ -234,7 +234,7 @@ class RemoteControlV2(BaseClass):
         self.msg_type = "blue_tooth"
         self.photo_take_state = 2
 
-    async def handlerTakePhoto(self, smart_shooter=None,session=None,record=None,PointName="A",action_id=None):
+    async def handlerTakePhoto(self, smart_shooter=None, session=None, record=None, PointName="A", action_id=None):
         """处理单独拍照"""
         await asyncio.sleep(0.1)
         print("开始单拍1")
@@ -280,13 +280,14 @@ class RemoteControlV2(BaseClass):
                 None,
             )
             CameraKey = camera_configs[PointName].get("CameraKey", None)
-            print("单排CameraKey",CameraKey)
+            print("单排CameraKey", CameraKey)
             loop = asyncio.get_event_loop()
             loop.create_task(
                 smart_shooter.CameraShooter(
                     msg_type="handler_take_picture",
                     goods_art_no=record.goods_art_no,
                     id=record_id,
+                    is_af=True,  # 增加强制自动对焦
                     CameraKey=CameraKey,
                 ),
                 name="CameraShooter",
@@ -371,7 +372,7 @@ class RemoteControlV2(BaseClass):
                 # 处理遥控器单拍
                 self.msg_type = "handler_take_picture"
                 # 0 闲置;1进行中;2已完成;
-                _data = {"type": self.msg_type,"data":None}
+                _data = {"type": self.msg_type, "data": None}
                 self.sendSocketMessage(0, "处理单拍消息", data=_data, device_status=-1)
                 self.msg_type = "blue_tooth"
             if button_value in [9]:
@@ -457,10 +458,10 @@ class RemoteControlV2(BaseClass):
 
         if receive_data[0] == 111:
             value = (
-                receive_data[1] << 24
-                | receive_data[2] << 16
-                | receive_data[3] << 8
-                | receive_data[4]
+                    receive_data[1] << 24
+                    | receive_data[2] << 16
+                    | receive_data[3] << 8
+                    | receive_data[4]
             )
             print("遥控器-测试   value:{}".format(value))
             # self.windows.show_label.setText("--------{}".format(value))
@@ -472,7 +473,7 @@ class RemoteControlV2(BaseClass):
 
     # 通用串口数据解析器
     def get_data_from_receive_data(
-        self, receive_data, start, len_data, data_magnification=1
+            self, receive_data, start, len_data, data_magnification=1
     ):
         # data_magnification 数据放大倍数,或缩小倍数,默认为1
         try:
@@ -484,10 +485,10 @@ class RemoteControlV2(BaseClass):
                 return data * data_magnification
             elif len_data == 4:
                 data = (
-                    receive_data[start] << 24
-                    | receive_data[start + 1] << 16
-                    | receive_data[start + 2] << 8
-                    | receive_data[start + 3]
+                        receive_data[start] << 24
+                        | receive_data[start + 1] << 16
+                        | receive_data[start + 2] << 8
+                        | receive_data[start + 3]
                 )
                 return data * data_magnification
             return None

+ 17 - 14
python/mcu/SerialIns.py

@@ -5,6 +5,7 @@ import logging
 
 logger = logging.getLogger(__name__)
 
+
 # https://blog.csdn.net/weixin_44289254/article/details/121583562
 
 
@@ -68,7 +69,7 @@ class SerialIns(object):
             buf.extend(data)
             buf.extend([0xFF & ~sum(data)])
             # 55 55 02 5a 01 a4
-            print("send buf  {}".format(self.change_hex_to_int(buf)))
+            # print("send buf  {}".format(self.change_hex_to_int(buf)))
             # logger.info("正在发送命令======>>>>> send buf  %s", self.change_hex_to_int(buf))
             try:
                 self.serial_handle.write(buf)
@@ -122,9 +123,9 @@ class SerialIns(object):
                     if time.time() - s > out_time:
                         time.sleep(0.01)
                         return
-                _data = self.receive_data[3 : data_len + 4]
+                _data = self.receive_data[3: data_len + 4]
                 # 更新缓存区
-                self.receive_data = self.receive_data[data_len + 4 :]
+                self.receive_data = self.receive_data[data_len + 4:]
                 # 校验数据
                 if check is None:
                     if 0xFF & ~sum(_data[:-1]) == _data[-1]:
@@ -151,7 +152,7 @@ class SerialIns(object):
                 if index >= len(self.receive_data):
                     self.receive_data = b""
                 else:
-                    self.receive_data = self.receive_data[index - 1 :]
+                    self.receive_data = self.receive_data[index - 1:]
 
     def read_cmd111(self, out_time=1, check=None):
         while 1:
@@ -180,7 +181,7 @@ class SerialIns(object):
                     #     return
                     return
 
-                _data = _recv_data[3 : data_len + 4]
+                _data = _recv_data[3: data_len + 4]
                 # 校验数据
                 if check is None:
                     if 0xFF & ~sum(_data[:-1]) == _data[-1]:
@@ -205,8 +206,8 @@ class SerialIns(object):
                             self.receive_data = b""
                     else:
                         if (
-                            self.receive_data[0] == 0x55
-                            and self.receive_data[1] == 0x55
+                                self.receive_data[0] == 0x55
+                                and self.receive_data[1] == 0x55
                         ):
                             break
                         else:
@@ -225,7 +226,8 @@ class SerialIns(object):
 
             if len(self.receive_data) < 4:
                 break
-            print("read ori ", self.change_hex_to_int(self.receive_data))
+            receive_data = self.change_hex_to_int(self.receive_data)
+            logger.info(f"read ori {receive_data}")
             if self.receive_data[0] == 0x55 and self.receive_data[1] == 0x55:
                 # print("read ori ", self.change_hex_to_int(self.receive_data))
                 data_len = self.receive_data[2]
@@ -240,9 +242,9 @@ class SerialIns(object):
                     #     return None
                     # time.sleep(0.01)
                     continue
-                _data = self.receive_data[3 : data_len + 4]
+                _data = self.receive_data[3: data_len + 4]
                 # 更新缓存区
-                self.receive_data = self.receive_data[data_len + 4 :]
+                self.receive_data = self.receive_data[data_len + 4:]
                 # 校验数据
                 if 0xFF & ~sum(_data[:-1]) == _data[-1]:
                     # print("receive_data:", self.change_hex_to_int(self.receive_data[:-1]))
@@ -260,8 +262,8 @@ class SerialIns(object):
                             self.receive_data = b""
                     else:
                         if (
-                            self.receive_data[0] == 0x55
-                            and self.receive_data[1] == 0x55
+                                self.receive_data[0] == 0x55
+                                and self.receive_data[1] == 0x55
                         ):
                             break
                         else:
@@ -305,14 +307,15 @@ class SerialIns(object):
     def __del__(self):
         self.close_serial_port()
 
+
 if __name__ == "__main__":
     s = SerialIns(port_name="COM5", baud=115200, timeout=0.1)
     s.scan_serial_port()
     s.clearn_flush()
     print("-" * 30)
     for i in range(2):
-        data = [2,99,1]
-        
+        data = [2, 99, 1]
+
         s.write_cmd(data)
 
         time.sleep(0.1)

+ 25 - 0
python/models.py

@@ -218,3 +218,28 @@ class OpenApiUploadItem(BaseModel):
     """拍照机商品资料上传"""
     goods_art_no: str = Field(default=None, description="货号")
     company_id: str = Field(default=None, description="混淆企业ID")
+    item_type: int = Field(default=None, description="上传类型:1商品 2材料")
+
+
+class ShoesComposeImageItem(BaseModel):
+    """拍照机商品资料上传"""
+    image_path: str = Field(default=None, description="货号")
+    color_name: str = Field(default=None, description="尺码")
+
+
+class ShoesComposeItem(BaseModel):
+    """拍照机商品资料上传"""
+    logo: str = Field(default=None, description="logo")
+    number_mid: dict = Field(default=None, description="中童货号")
+    size_mid: dict = Field(default=None, description="中童尺码")
+    number_big: dict = Field(default=None, description="大童货号")
+    size_big: dict = Field(default=None, description="大童尺码")
+    box: dict = Field(default=None, description="装箱数量")
+    image_path: str = Field(default=None, description="主图")
+    reference_image: str = Field(default=None, description="参考图")
+    images: list[dict] = Field(default=None, description="图片货号列表")
+
+
+class ShoesComposes(BaseModel):
+    """拍照机商品资料上传"""
+    data: list[ShoesComposeItem] = Field(default=None, description="结构体")

+ 85 - 23
python/service/deal_image.py

@@ -1,11 +1,11 @@
-import os,re
+import os, re
 
-from  natsort import natsorted,ns
+from natsort import natsorted, ns
 import shutil
 import exifread
 import time
 import datetime
-from databases import PhotoRecord,SqlQuery,CRUD
+from databases import PhotoRecord, SqlQuery, CRUD
 import settings
 import copy
 import json
@@ -17,6 +17,7 @@ from PIL import Image
 from utils.utils_func import check_path
 from .base_deal import BaseDealImage
 from middleware import UnicornException
+
 _Type = ['.png', '.PNG', '.jpg', '.JPG', '.gif', '.GIF', ".jpge", ".JPGE"]
 
 
@@ -26,16 +27,20 @@ class DealImage(BaseDealImage):
         super().__init__()
         self.image_dir = image_dir
         self.header = None
+
     def check_path(self, image_dir: str):
         if not os.path.exists(image_dir):
             os.mkdir(image_dir)
         return True
+
     def list_dir(self, path):
         listdir = os.listdir(path)
-        sort_result = sorted(listdir, key=lambda x: int(re.findall(r'^(\d+)_', x)[0]) if re.findall(r'^(\d+)_', x) else 0)
+        sort_result = sorted(listdir,
+                             key=lambda x: int(re.findall(r'^(\d+)_', x)[0]) if re.findall(r'^(\d+)_', x) else 0)
         # print("listdir   排序排序排序排序排序排序", sort_result)
         # return natsorted(listdir, alg=ns.PATH)
         return sort_result
+
     def get_date_time_original(self, file_path):
         with open(file_path, 'rb') as file_data:
             tags = exifread.process_file(file_data)
@@ -54,7 +59,7 @@ class DealImage(BaseDealImage):
         configModel = CRUD(PhotoRecord)
         result = configModel.read(
             session,
-            conditions={"goods_art_no": goods_art_no,"delete_time": None},
+            conditions={"goods_art_no": goods_art_no, "delete_time": None},
             order_by="id",
             ascending=True,
         )
@@ -63,6 +68,7 @@ class DealImage(BaseDealImage):
             return result.goods_art_no, result.image_index, result.image_deal_mode
         else:
             return None
+
     def get_goods_art_no_files(self, goods_art_no):
         # time_array = time.strptime(date_time_original, "%Y:%m:%d %H:%M:%S")
 
@@ -73,15 +79,16 @@ class DealImage(BaseDealImage):
         configModel = CRUD(PhotoRecord)
         result = configModel.read_all(
             session,
-            conditions={"goods_art_no": goods_art_no,"delete_time": None},
+            conditions={"goods_art_no": goods_art_no, "delete_time": None},
             order_by="id",
             ascending=True,
         )
         session.close()
         if result:
-            return [{"image_path":item.image_path,"id":item.id,"image_index":item.image_index} for item in result]
+            return [{"image_path": item.image_path, "id": item.id, "image_index": item.image_index} for item in result]
         else:
             return []
+
     def get_data_from_hqt_with_goods_art_no(self, goods_art_no_list):
         _goods_art_no_list = copy.deepcopy(goods_art_no_list)
         _list = []
@@ -196,6 +203,7 @@ class DealImage(BaseDealImage):
             shutil.move(old_image_path, original_image_path)
         except Exception as e:
             print(f"文件操作异常:{e}")
+
     def copy_images(self, goods_art_no, goods_art_no_path, old_image_path):
         """
         步骤:
@@ -221,18 +229,19 @@ class DealImage(BaseDealImage):
         original_image_path = "{}/原始图/{}{}".format(goods_art_no_path, new_file_name, e)
         try:
             shutil.copy2(old_image_path, original_image_path)
-            return True,"移动成功"
+            return True, "移动成功"
         except Exception as e:
             print(f"文件操作异常:{e}")
-            return False,f"货号[{goods_art_no}]原始图{old_image_path}不存在或已被删除"
-    def dealMoveImage(self, image_dir: str, callback_func=None,goods_art_no=None) -> dict:
+            return False, f"货号[{goods_art_no}]原始图{old_image_path}不存在或已被删除"
+
+    def dealMoveImage(self, image_dir: str, callback_func=None, goods_art_no=None) -> dict:
         if not self.check_path(image_dir=image_dir + "/历史"):
             raise UnicornException("文件夹创建失败")
 
         # 遍历目标文件夹,获取有拍摄信息的图片,并按拍摄时间排序
         files = self.list_dir(image_dir)
         original_photo_list = []  # 原始图片列表
-        for file_idx,file in enumerate(files):
+        for file_idx, file in enumerate(files):
             # -----图片清洗
             file_path = image_dir + "/" + file
             if os.path.isdir(file_path):  # 忽略文件夹
@@ -249,7 +258,7 @@ class DealImage(BaseDealImage):
                 if _data:
                     # 能匹配上数据库
                     goods_art_no, image_index, image_deal_mode = _data
-                    print("832 与数据库匹配goods_art_no", file_path,file_name, date_time_original, goods_art_no)
+                    print("832 与数据库匹配goods_art_no", file_path, file_name, date_time_original, goods_art_no)
                     original_photo_list.append({"file_path": file_path,
                                                 "file": file,
                                                 "date_time_original": date_time_original,
@@ -280,14 +289,15 @@ class DealImage(BaseDealImage):
         total_num = len(original_photo_list)
         # 当天日期作为文件夹
         seconds = time.time()
-        output_path = "{}/{f_name}".format(settings.OUTPUT_DIR,f_name=time.strftime("%Y-%m-%d", time.localtime(seconds)))
+        output_path = "{}/{f_name}".format(settings.OUTPUT_DIR,
+                                           f_name=time.strftime("%Y-%m-%d", time.localtime(seconds)))
 
         # 遍历每个匹配好的数据进行处理
-        print(f"original_photo_list:",original_photo_list)
+        print(f"original_photo_list:", original_photo_list)
         n = 0
         for photo_dict in original_photo_list:
             n += 1
-            print("处理dict:",photo_dict)
+            print("处理dict:", photo_dict)
             # 进度条
             goods_art_no = photo_dict["goods_art_no"]
             original_image_path = photo_dict["file_path"]
@@ -325,13 +335,61 @@ class DealImage(BaseDealImage):
         # 完成处理
         # self.set_state(state_value=2)
         return True, output_path
-    def dealMoveImageV2(self,goods_art_no=None) -> dict:
+
+    def dealMoveImageV2(self, goods_art_no=None) -> dict:
+        # 遍历目标文件夹,获取有拍摄信息的图片,并按拍摄时间排序
+        original_photo_list = []  # 原始图片列表
+        _data = self.get_goods_art_no_files(goods_art_no)
+        if _data:
+            for file_idx, dataItem in enumerate(_data):
+                # 能匹配上数据库
+                file_path = dataItem['image_path']
+                original_photo_list.append({"file_path": file_path,
+                                            "goods_art_no": goods_art_no,
+                                            "image_index": file_idx,
+                                            })
+        if not original_photo_list:
+            raise UnicornException("没有任何匹配的图片")
+        # 当天日期作为文件夹
+        seconds = time.time()
+        output_path = "{}/{f_name}".format(settings.OUTPUT_DIR,
+                                           f_name=time.strftime("%Y-%m-%d", time.localtime(seconds)))
+
+        # 遍历每个匹配好的数据进行处理
+        print(f"original_photo_list:", original_photo_list)
+        n = 0
+        for photo_dict in original_photo_list:
+            n += 1
+            print("处理dict:", photo_dict)
+            # 进度条
+            goods_art_no = photo_dict["goods_art_no"]
+            original_image_path = photo_dict["file_path"]
+            goods_art_no_path = "{output_path}/{goods_art_no}".format(output_path=output_path,
+                                                                      goods_art_no=goods_art_no)
+            # 创建货号下的一系列文件夹
+            self.create_folder(goods_art_no_path)
+            # 重命名并进行移动
+            print("开始移动:{}  {} 命名为:{}".format(goods_art_no, original_image_path, goods_art_no_path))
+            result, message = self.copy_images(goods_art_no, goods_art_no_path, original_image_path)  # 货号、货号文件路径、原始图路径
+            if not result:
+                return result, message
+            time.sleep(0.1)
+            print("已完成移动处理")
+        if n != 0:
+            # 自动生成一个货号表
+            print("output_path", output_path)
+            self.deal(output_path)
+        # 完成处理
+        # self.set_state(state_value=2)
+        return True, output_path
+
+    def dealImagePath(self, goods_art_no=None) -> dict:
         # 遍历目标文件夹,获取有拍摄信息的图片,并按拍摄时间排序
         original_photo_list = []  # 原始图片列表
         _data = self.get_goods_art_no_files(goods_art_no)
         if _data:
             for file_idx, dataItem in enumerate(_data):
-            # 能匹配上数据库
+                # 能匹配上数据库
                 file_path = dataItem['image_path']
                 original_photo_list.append({"file_path": file_path,
                                             "goods_art_no": goods_art_no,
@@ -341,14 +399,15 @@ class DealImage(BaseDealImage):
             raise UnicornException("没有任何匹配的图片")
         # 当天日期作为文件夹
         seconds = time.time()
-        output_path = "{}/{f_name}".format(settings.OUTPUT_DIR,f_name=time.strftime("%Y-%m-%d", time.localtime(seconds)))
+        output_path = "{}/{f_name}".format(settings.OUTPUT_DIR,
+                                           f_name=time.strftime("%Y-%m-%d", time.localtime(seconds)))
 
         # 遍历每个匹配好的数据进行处理
-        print(f"original_photo_list:",original_photo_list)
+        print(f"original_photo_list:", original_photo_list)
         n = 0
         for photo_dict in original_photo_list:
             n += 1
-            print("处理dict:",photo_dict)
+            print("处理dict:", photo_dict)
             # 进度条
             goods_art_no = photo_dict["goods_art_no"]
             original_image_path = photo_dict["file_path"]
@@ -370,6 +429,7 @@ class DealImage(BaseDealImage):
         # 完成处理
         # self.set_state(state_value=2)
         return True, output_path
+
     def deal(cls, dir_path):
 
         # print("dir_path", dir_path)
@@ -412,7 +472,9 @@ class DealImage(BaseDealImage):
                     continue
 
                 all_original_images = os.listdir(_path)  # 遍历货号原始图文件夹
-                sort_result = sorted(all_original_images, key=lambda x: int(re.findall(r'((\d+))', x)[0]) if re.findall(r'((\d+))', x) else 0)
+                sort_result = sorted(all_original_images,
+                                     key=lambda x: int(re.findall(r'((\d+))', x)[0]) if re.findall(r'((\d+))',
+                                                                                                   x) else 0)
                 goods_pic_total = len(sort_result)
                 if not sort_result:
                     continue
@@ -492,9 +554,9 @@ class DealImage(BaseDealImage):
                             "y_scale": y_scale,
                             "x_offset": 5,
                             "y_offset": 5,
-                            "object_position":1,
+                            "object_position": 1,
                         },
-                        )
+                    )
 
                 except:
                     pass

+ 75 - 5
python/service/remove_bg_ali.py

@@ -140,7 +140,7 @@ class Segment(object):
         if not settings.USER_TOKEN:
             print("错误:USER_TOKEN 未配置或为空")
             return None
-        print("图片上传成功", imageUrl)
+        # print("图片上传成功", imageUrl)
         post_headers = {"Authorization": "Bearer " + settings.USER_TOKEN}
         data = {
             "image_url": imageUrl,
@@ -149,19 +149,19 @@ class Segment(object):
         response = requests.post(
             api_url, json=data, headers=post_headers
         )
-        print(f"响应状态码: {response.status_code}")
-        print(f"响应内容: {response.text[:500]}")  # 只打印前500字符
+        # print(f"响应状态码: {response.status_code}")
+        # print(f"响应内容: {response.text[:500]}")  # 只打印前500字符
 
         resultData = response.json()
         print("旗舰版抠图请求", resultData)
         # 安全地获取返回值
         data = resultData.get("data")
         if data is None:
-            print("旗舰版抠图返回的data为空")
+            # print("旗舰版抠图返回的data为空")
             return None
         cutout_image = data.get("cutout_image")
         if cutout_image is None:
-            print("旗舰版抠图返回的cutout_image为空")
+            # print("旗舰版抠图返回的cutout_image为空")
             return None
         return cutout_image
 
@@ -726,6 +726,76 @@ class RemoveUltraBackground:
             )
         return _img_im
 
+    @func_set_timeout(40)
+    def ultra_segment_fast_pil(self, file_path):
+        '''
+        旗舰抠图快速版
+        '''
+        original_pic = Picture(file_path)
+        original_pic.im = self.segment.get_image_orientation(original_pic.im)
+        original_pic.x, original_pic.y = original_pic.im.size
+        if original_pic.im.mode != "RGB":
+            print("抠图图片不能是PNG")
+            return None
+
+        new_pic = copy.copy(original_pic)
+        after_need_resize = False
+        if new_pic.x > new_pic.y:
+            if new_pic.x > 2000:
+                after_need_resize = True
+                new_pic.resize(2000)
+        else:
+            if new_pic.y > 2000:
+                after_need_resize = True
+                new_pic.resize_by_heigh(heigh=2000)
+        # print("使用旗舰版抠图")
+        try:
+            api_url = f"{settings.DOMAIN}{self.api_url}"
+            image_url = self.segment.get_bo_bg_goods_ultra_background(im=new_pic.im, api_url=api_url)
+        except BaseException as e:
+            print("旗舰版抠图异常:", e)
+            # 处理失败,需要删除过程图片
+            return None
+        if image_url is None:
+            return None
+        # 字节流转PIL对象
+        # print("image_url", image_url)
+        response = requests.get(image_url)
+        pic = response.content
+        _img_im = Image.open(BytesIO(pic))  # 阿里返回的抠图结果 已转PIL对象
+        # 原图更大,则需要执行CV处理
+        if after_need_resize:
+            # 将抠图结果转成mask
+            # _img_im = Image.open(_path)
+            # 将抠图结果放大到原始图大小
+            _img_im = _img_im.resize(original_pic.im.size)
+            new_big_mask = Image.new('RGB', _img_im.size, (0, 0, 0))
+            white = Image.new('RGB', _img_im.size, (255, 255, 255))
+            new_big_mask.paste(white, mask=_img_im.split()[3])
+
+            # ---------制作选区缩小的mask
+            # mask = cv2.imread(mask_path)
+            # mask = cv2.cvtColor(mask, cv2.COLOR_BGR2GRAY)
+            mask = cv2.cvtColor(np.asarray(new_big_mask), cv2.COLOR_BGR2GRAY)  # 将PIL 格式转换为 CV对象
+            mask[mask != 255] = 0
+            # 黑白反转
+            # mask = 255 - mask
+            # 选区缩小10
+            kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (10, 10))
+            erode_im = cv2.morphologyEx(mask, cv2.MORPH_ERODE, kernel)
+
+            # -------再进行抠图处理
+            mask = Image.fromarray(cv2.cvtColor(erode_im, cv2.COLOR_GRAY2RGBA))  # CV 对象转 PIL
+            transparent_im = Image.new('RGBA', original_pic.im.size, (0, 0, 0, 0))
+            # original_pic.im.show()
+            # mask.show()
+            transparent_im.paste(original_pic.im, (0, 0), mask.convert('L'))
+            # transparent_im.show()
+            # 上述抠图结果进行拼接
+            _img_im.paste(transparent_im, (0, 0), transparent_im)
+        # 原图更大,则需要执行CV处理
+        return _img_im
+
     def run_ultra_segment(self, file_path, out_file_path):
         # 直接调用抠图
         time.sleep(0.01)

+ 500 - 0
python/service/shoe_main_image_compose.py

@@ -0,0 +1,500 @@
+# -*- 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

+ 28 - 0
python/sockets/message_handler.py

@@ -865,6 +865,34 @@ async def handlerSend(
                 websocket_manager=manager, smart_shooter=smart_shooter
             )
             device_ctrl.set_stepper_disable("mcu", False)
+        case "mcu_power_off":
+            device_ctrl = DeviceControl(
+                websocket_manager=manager, smart_shooter=smart_shooter
+            )
+            device_ctrl.one_key_turnoff("mcu", False)
+        case "delete_photo_record":  # 重拍
+            msg_type = "delete_photo_record"
+            record_id = data.get("record_id")
+            session = SqlQuery()
+            photoRecord = CRUD(PhotoRecord)
+            goods_art_record = photoRecord.read(session, conditions={"id": record_id, "delete_time": None})
+            if goods_art_record == None:
+                data = manager.jsonMessage(
+                    code=1,
+                    msg=f"记录不存在,请核实后重新操作~",
+                    msg_type=msg_type,
+                )
+                await manager.send_personal_message(data, websocket)
+                return
+            photoRecord.deleteConditions(session, conditions={"id": record_id, "delete_time": None})
+            session.close()
+            data = manager.jsonMessage(
+                code=0,
+                msg=f"记录删除成功",
+                msg_type=msg_type,
+            )
+            await manager.send_personal_message(data, websocket)
+            return
         case _:
             data = manager.jsonMessage(code=1, msg="未知消息")
             await manager.send_personal_message(data, websocket)