Explorar el Código

Merge remote-tracking branch 'origin/anheng' into feature-frontend

panqiuyao hace 4 días
padre
commit
d3feb85a1a

+ 25 - 10
python/api.py

@@ -288,6 +288,7 @@ async def process_handle_detail(request: Request, params: HandlerDetail):
         is_upper_footer = params.is_upper_footer
         upper_footer_params = params.upper_footer_params
         product_scene_prompt = params.product_scene_prompt
+        product_scene_image = params.product_scene_image
 
         if params.excel_path and params.excel_path.strip():
             # 处理Excel模式
@@ -349,7 +350,7 @@ async def process_handle_detail(request: Request, params: HandlerDetail):
         # 处理场景图
         if is_product_scene == 1:
             return_data_check_before_detail = await _process_scene_images(
-                aigc_clazz, run_main, return_data_check_before_detail, product_scene_prompt
+                aigc_clazz, run_main, return_data_check_before_detail, product_scene_prompt, product_scene_image
             )
 
         # 处理模特图
@@ -624,7 +625,8 @@ async def _process_cutout(run_main, config_data, goods_art_no_arrays, move_folde
     return handler_result_folder, handler_result
 
 
-async def _process_single_scene_item(aigc_clazz, goods_art_dict_info_item, product_scene_prompt, save_root_path):
+async def _process_single_scene_item(aigc_clazz, goods_art_dict_info_item, product_scene_prompt, save_root_path,
+                                     product_scene_image):
     """将单个货号的场景图生成逻辑提取出来,放在线程池中运行"""
 
     def sync_generate():
@@ -635,7 +637,8 @@ async def _process_single_scene_item(aigc_clazz, goods_art_dict_info_item, produ
             return save_image_path
 
         aigc_clazz.center_paste_image(ceshi_image_path, save_image_path)
-        image_path = aigc_clazz.generateProductScene(save_image_path, product_scene_prompt, save_image_path)
+        image_path = aigc_clazz.generateProductScene(save_image_path, product_scene_prompt, save_image_path,
+                                                     product_scene_image)
         return image_path
 
     loop = asyncio.get_event_loop()
@@ -646,7 +649,8 @@ async def _process_single_scene_item(aigc_clazz, goods_art_dict_info_item, produ
         return {"success": False, "error": str(e)}
 
 
-async def _process_scene_images(aigc_clazz, run_main, return_data_check_before_detail, product_scene_prompt):
+async def _process_scene_images(aigc_clazz, run_main, return_data_check_before_detail, product_scene_prompt,
+                                product_scene_image):
     """处理场景图生成"""
     # 参数验证
     if not product_scene_prompt:
@@ -673,7 +677,7 @@ async def _process_scene_images(aigc_clazz, run_main, return_data_check_before_d
         msg_type="scene_progress",
         progress=product_scene_progress
     )
-    loop = asyncio.get_event_loop()
+    # loop = asyncio.get_event_loop()
     for goods_art_no_info in goods_dict.keys():
         goods_art_dict_info = goods_dict.get(goods_art_no_info, None)
         new_goods_dict.setdefault(goods_art_no_info, goods_art_dict_info)
@@ -699,13 +703,16 @@ async def _process_scene_images(aigc_clazz, run_main, return_data_check_before_d
                 progress=product_scene_progress
             )
             first_pics = goods_art_dict_info_item.get("pics")
-            ceshi_image_path = first_pics.get("侧视-抠图")
+            if len(first_pics.items()) == 1:
+                ceshi_image_path = first_pics.get("俯视-抠图")
+            else:
+                ceshi_image_path = first_pics.get("侧视-抠图")
             save_root_path = ceshi_image_path.split("阴影图处理")[0]
             save_image_path = f"{save_root_path}场景图.jpg"
             # 【修复】异步等待单个货号的 AI 处理完成
             # 这样在处理一个货号时,事件循环可以去发送其他货号的消息
             result = await _process_single_scene_item(aigc_clazz, goods_art_dict_info_item, product_scene_prompt,
-                                                      save_root_path)
+                                                      save_root_path, product_scene_image)
 
             if result["success"]:
                 goods_art_dict_info_item["场景图"] = result["path"]
@@ -834,7 +841,10 @@ async def _process_model_images(aigc_clazz, run_main, return_data_check_before_d
             gender = goods_art_dict_info.get("性别", "女")
             model_id = man_id if "男" in gender else women_id
             first_pics = goods_art_dict_info_item.get("pics")
-            ceshi_image_path = first_pics.get("侧视-抠图")
+            if len(first_pics.items()) == 1:
+                ceshi_image_path = first_pics.get("俯视-抠图")
+            else:
+                ceshi_image_path = first_pics.get("侧视-抠图")
             save_root_path = ceshi_image_path.split("阴影图处理")[0]
             save_image_path = f"{save_root_path}模特图.jpg"
 
@@ -1815,6 +1825,8 @@ async def remove_background(params: PhotoRecordRemoveBackground):
             cutOutMode = "2"
         case "旗舰AI抠图":
             cutOutMode = "3"
+        case "旗舰AI抠图-精细":
+            cutOutMode = "4"
         case _:
             cutOutMode = "1"
     config_data = {
@@ -2459,16 +2471,19 @@ async def shoeCompose(params: ShoesComposes):
         for img in item.images:
             sub_image_path = img.get("image_path")
             img['image_path'] = await cutOutImage(sub_image_path)
+        # print("item dict", item.__dict__)
         result_url = compose_shoe_main_image(template=tpl,
                                              data=item.__dict__,
-                                             label_pos="right",
+                                             label_pos="bottom-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)
+                                             info_font_size=34,
+                                             label_offset=(0, 0)
+                                             )
         postData.append({"base_image": main_image_url, "reference_image": reference_image, 'past_image': result_url})
     headers = {
         "Authorization": f"Bearer {settings.USER_TOKEN}",

+ 47 - 13
python/mcu/DeviceControl.py

@@ -23,6 +23,7 @@ import logging
 from mcu.capture.smart_shooter_class import SmartShooter
 import logging
 from conifg_info import ConfigManager
+from mcu.anheng_scale import AnhengScale
 
 logger = logging.getLogger(__name__)
 
@@ -41,6 +42,7 @@ class DeviceControl(BaseClass, metaclass=SingletonType):
         self.camera_height = 400
         self.config_manager = None
         self.msg_type = "mcu"
+        self.anheng_scale = AnhengScale(baudrate=9600, websocket_manager=self.websocket_manager)
         self.command = {
             "to_device_move": 1,  # 设备运动
             'to_init_device': 2,  # 初始化设备
@@ -1158,6 +1160,16 @@ class DeviceControl(BaseClass, metaclass=SingletonType):
                     "product": port.product,
                     "serial_number": port.serial_number,
                 }
+            elif "Prolific USB-to-Serial Comm Port" in port.description:
+                ports_dict[port.name] = {
+                    "name": port.name,
+                    "device": port.device,
+                    "description": port.description,
+                    "hwid": port.hwid,
+                    "manufacturer": port.manufacturer,
+                    "product": port.product,
+                    "serial_number": port.serial_number,
+                }
         if len(ports_dict) <= 0:
             return {}
         return ports_dict
@@ -1181,10 +1193,11 @@ class DeviceControl(BaseClass, metaclass=SingletonType):
         except serial.serialException:
             return False
 
-    async def add_port_by_linkage(self, port_name):
+    async def add_port_by_linkage(self, port_name, _port_value):
         # port_value :串口基础信息
         # todo 根据prot_value 信息自动进行连接
         print("add port_name", port_name)
+        print("add port_value", _port_value)
         logger.info("add port_name %s", port_name)
         print("self.port_name", self.port_name)
         logger.info("self.port_name %s", self.port_name)
@@ -1272,8 +1285,12 @@ class DeviceControl(BaseClass, metaclass=SingletonType):
                 connect_flag = receive_data[1]
                 device_id = receive_data[2]
         else:
-            logger.info("串口数据未监听到,继续等待...%s", port_name)
-            return False
+            if "Prolific USB-to-Serial Comm Port" in _port_value.get('description'):
+                # 电子秤不会应答探测命令,收不到数据属正常现象,落到下方统一连接
+                pass
+            else:
+                logger.info("串口数据未监听到,继续等待...%s", port_name)
+                return False
         print("关闭串口:{}".format(port_name))
         logger.info("关闭串口:%s", port_name)
         serial_handle.close()
@@ -1315,16 +1332,30 @@ class DeviceControl(BaseClass, metaclass=SingletonType):
                 return True
 
         else:
-            print("串口无法识别")
-            logger.info("串口无法识别")
-            self.sendSocketMessage(
-                code=1,
-                msg="串口无法识别,请重新插拔拍照机USB",
-                data=message_data,
-                device_status=-1,
-            )
+            if "Prolific USB-to-Serial Comm Port" in _port_value.get('description'):
+                # 探测用的临时串口已在上方 close(Windows同一COM口不能重复打开)
+                # probe_connect 内部以9600重新打开并校验安衡协议特征,
+                # 确认是电子秤才真正连接,避免误占其他 Prolific 设备的串口
+                anheng_scale_ok = self.anheng_scale.probe_connect(port_name, True)
+                if not anheng_scale_ok:
+                    print("安衡电子秤 识别失败(串口打开失败或非电子秤),稍后重试")
+                    return False
+                else:
+                    print("安衡电子秤 识别成功,串口已打开")
+                    # 循环推送电子秤数据消息(参考 LineControl 模式:create_task(run()))
+                    self.anheng_scale.start_run()
+                    return True
+            else:
+                print("串口无法识别")
+                logger.info("串口无法识别")
+                self.sendSocketMessage(
+                    code=1,
+                    msg="串口无法识别,请重新插拔拍照机USB",
+                    data=message_data,
+                    device_status=-1,
+                )
             return False
-            # 走其他途径处理
+        # 走其他途径处理
 
     def clearMyInstance(self):
         SingletonType.clear_instance()
@@ -1464,6 +1495,9 @@ class DeviceControl(BaseClass, metaclass=SingletonType):
         print("关闭有线遥控器")
         logger.info("关闭有线遥控器")
 
+    def close_anheng_scale(self):
+        self.anheng_scale.disconnect()
+
     @property
     def mcu_move_state(self):
         if self.is_get_mcu_state is False:
@@ -2091,7 +2125,7 @@ async def checkMcuConnection(device_ctrl: DeviceControl):
             for _port_name, _port_value in ports_dict.items():
                 if _port_name not in device_ctrl.p_list:
                     try:
-                        flag = await device_ctrl.add_port_by_linkage(_port_name)
+                        flag = await device_ctrl.add_port_by_linkage(_port_name, _port_value)
                         if not flag:
                             continue
                         device_ctrl.p_list.append(_port_name)

+ 552 - 0
python/mcu/anheng_scale.py

@@ -0,0 +1,552 @@
+import asyncio
+import re
+import serial
+import serial.tools.list_ports
+import threading
+import time
+from typing import Optional, Dict, Any
+from utils.common import message_queue
+from sockets import ConnectionManager
+
+
+class AnhengScale:
+    """
+    安衡电子秤 纯后台串口类,无GUI(单例)
+    协议格式示例: ST,TR,+ 0.0666kg
+    波特率默认9600,8N1
+
+    实时连接检测 + 自动重连:
+    - 读取线程通过两种方式判定设备断开:
+      (1) 读取异常/连续多次读失败 —— 覆盖"拔出USB"(COM句柄失效,读取会抛异常);
+      (2) 数据看门狗超时(默认开启 data_timeout=3.0s)—— 覆盖"设备断电但USB仍连着",
+          此时COM仍能正常打开但秤不再发数据,靠"一段时间收不到任何数据"来判定断电;
+    - 断开后由后台重连线程重新扫描串口(兼容重插后COM号变化),
+      按设备特征识别安衡电子秤并严格探测确认(需收到数据)后重连,指数退避重试直至成功或被显式关闭;
+      设备重新上电 / 重新插拔USB后会自动恢复连接。
+    - 连接状态(connected/disconnected/reconnecting/reconnected/closed)
+      通过全局消息队列推送(msg_type=anheng_scale_status),前端可选消费。
+    """
+    # 安衡问答模式指令(秤设置COMMOD=5时可用)
+    CMD_TARE = "ST\r\n"  # 硬件去皮
+    CMD_ZERO = "SZ\r\n"  # 硬件归零
+    CMD_READ_NET = "RN\r\n"  # 读取净重
+
+    WEIGHT_RE = re.compile(r"([+-]?\s*\d+(?:\.\d+)?)\s*(kg|g)", re.IGNORECASE)
+
+    # ===== 实时连接检测 + 自动重连 参数 =====
+    # 安衡电子秤串口设备特征(用于重插后重新扫描识别)
+    SCALE_DESC_KEYWORD = "Prolific USB-to-Serial Comm Port"
+    RECONNECT_INITIAL_DELAY = 1.0  # 首次重连等待(秒)
+    RECONNECT_MAX_DELAY = 5.0  # 重连退避上限(秒)
+    RECONNECT_STABLE_WAIT = 1.0  # 重连成功后稳定观察(秒)
+    READ_ERROR_THRESHOLD = 3  # 连续读取异常达到该次数即判定设备断开
+
+    # 单例:用 __new__ 方式(与 BlueToothMode/Mcu 一致)。
+    # 不能用 SingletonType 元类:它的 _instance_lock 是全部单例类共享的不可重入锁,
+    # AnhengScale 在 DeviceControl.__init__ 内创建时(DeviceControl 也是 SingletonType 单例)会死锁
+    _instance = None
+
+    def __new__(cls, *args, **kwargs):
+        """如果当前没有实例时,调用父类__new__方法,生成实例,有则返回保存的实例。"""
+        if cls._instance is None:
+            cls._instance = super().__new__(cls)
+        return cls._instance
+
+    def __init__(
+            self,
+            baudrate: int = 9600,
+            websocket_manager: Optional[ConnectionManager] = None,
+            auto_reconnect: bool = True,
+            data_timeout: Optional[float] = 3.0,
+    ):
+        # __init__ 每次调用 AnhengScale(...) 都会执行,只允许初始化一次
+        if getattr(self, "_initialized", False):
+            return
+        self._initialized = True
+        self.baudrate = baudrate
+        self.is_send_message = False
+        # 用于向推送电子秤数据消息(与 BaseClass.sendSocketMessage 同通道)
+        self.websocket_manager = websocket_manager
+        self.ser: Optional[serial.Serial] = None
+
+        # 串口缓冲区,处理粘包
+        self._buf = b""
+
+        # 解析得到的实时状态
+        self.latest_data: Dict[str, Any] = {
+            "stable": None,  # True稳定 / False不稳定 / None无数据
+            "mode": None,  # TR净重 / NT毛重 / None
+            "raw_grams": 0.0,  # 原始重量(克),秤上报的原始值
+            "net_grams": 0.0,  # 软件去皮后净重 = raw_grams - tare_grams
+        }
+        # 软件本地皮重(程序层面去皮,不操作秤硬件)
+        self.tare_grams: float = 0.0
+
+        # 后台读取线程标记
+        self._running = False
+        self._read_thread: Optional[threading.Thread] = None
+
+        # 数据推送循环标记(防止重复创建 run 任务)
+        self._pushing = False
+
+        # ===== 实时连接检测 + 自动重连 状态 =====
+        self._loop = None  # 主事件循环(异步上下文进入时捕获,供后台线程安全推送状态)
+        self._port_name: str = ""  # 当前/上次串口号(重插后可能变化,仅作优先提示)
+        self._should_run: bool = False  # 电子秤处于"应连接"状态(start_run 开启,disconnect 关闭)
+        self._manually_closed: bool = False  # 是否被显式 disconnect() 关闭(关闭后不再自动重连)
+        self._device_lost: bool = False  # 是否已检测到设备断开
+        self._auto_reconnect: bool = auto_reconnect  # 是否启用自动重连
+        # 数据看门狗超时(秒):连续输出模式下,超过该时长未收到任何数据即判定"设备断电/失联"并触发断开重连。
+        # 连续模式约200ms上报一行,默认 3.0s(≈15 行未上报)足够判定断电且不易误判;
+        # 调小=断电检测更快但更敏感,调大=更不易误判。问答模式(COMMOD=5)无主动上报,请传 None 关闭以免误判。
+        self.data_timeout: Optional[float] = data_timeout
+        self._last_data_time: float = 0.0  # 最近一次收到有效数据的时间
+        self._reconnecting: bool = False  # 重连线程是否存活
+        self._reconnect_thread: Optional[threading.Thread] = None
+
+    @staticmethod
+    def get_all_com_ports() -> list[dict]:
+        """获取本机全部串口列表,返回 [{"device":"COM3","desc":"xxx"},...]"""
+        res = []
+        for p in serial.tools.list_ports.comports():
+            res.append({
+                "device": p.device,
+                "desc": p.description
+            })
+        return res
+
+    def _capture_loop(self):
+        """在异步上下文中捕获主事件循环;非异步上下文(如重连线程)则保留已有值,避免误建循环。"""
+        try:
+            self._loop = asyncio.get_running_loop()
+        except RuntimeError:
+            pass
+
+    def connect(self, port_name: str) -> bool:
+        """
+        打开串口,启动后台线程持续接收数据
+        :param port_name: 串口名称,如 "COM3"
+        :return: True成功 / False失败
+        """
+        self._capture_loop()
+        self._port_name = port_name
+        self._manually_closed = False
+
+        if self.is_connected():
+            self._close_serial_link()
+
+        try:
+            self.ser = serial.Serial(
+                port=port_name,
+                baudrate=self.baudrate
+            )
+        except Exception as e:
+            print(f"打开串口失败: {e}")
+            self.ser = None
+            return False
+
+        self._buf = b""
+        self._last_data_time = time.time()
+        self._running = True
+        self._should_run = True  # 连接即视为"应连接",断开后可自动重连
+        self._device_lost = False  # 连接成功,清除丢失标记
+        # 启动后台读取线程
+        self._read_thread = threading.Thread(target=self._background_read_loop, daemon=True)
+        self._read_thread.start()
+        return True
+
+    # 探测窗口:连续输出模式下秤约每200ms上报一行,1.2s足以收到多行
+    PROBE_TOTAL_WAIT = 1.2
+    # 被动收集0.5s仍无数据时,发送RN主动询问(兼容COMMOD=5问答模式)
+    PROBE_CMD_DELAY = 0.5
+
+    def probe_connect(self, port_name: str, verify: bool = False) -> bool:
+        """
+        探测并连接:先以9600打开串口收集数据,确认是安衡电子秤后才真正连接。
+        用于自动识别场景,避免把其他设备(如同样使用Prolific适配器的MCU)误认成电子秤。
+        :param port_name: 串口名称,如 "COM3"
+        :param verify: True=严格模式,未确认安衡协议特征则返回False(自动重连时使用,避免误连其他Prolific设备);
+                       False=沿用原有行为(打开成功即视为连接),用于首次识别
+        :return: True是电子秤且已连接 / False不是电子秤(串口已关闭,可稍后重试)
+        """
+        self._capture_loop()
+        self._port_name = port_name
+        self._manually_closed = False
+
+        if self.is_connected():
+            self._close_serial_link()
+        try:
+            ser = serial.Serial(port=port_name, baudrate=self.baudrate, timeout=0.1)
+        except Exception as e:
+            print(f"打开串口失败: {e}")
+            return False
+        try:
+            ser.reset_input_buffer()
+        except Exception:
+            pass
+
+        collected = b""
+        start = time.time()
+        cmd_sent = False
+        while time.time() - start < self.PROBE_TOTAL_WAIT:
+            try:
+                if ser.in_waiting:
+                    collected += ser.read(ser.in_waiting)
+            except Exception:
+                pass
+            # 连续模式下很快就有数据;问答模式(COMMOD=5)无主动上报,发RN询问
+            if not cmd_sent and time.time() - start >= self.PROBE_CMD_DELAY:
+                try:
+                    ser.write(b"RN\r\n")
+                    cmd_sent = True
+                except Exception as e:
+                    print("电子秤连接异常", e)
+                    pass
+            if self._is_anheng_data(collected):
+                break
+            time.sleep(0.05)
+
+        # 严格模式:未确认安衡协议特征则判定非电子秤(用于自动重连)
+        if verify and not self._is_anheng_data(collected):
+            print(f"探测{port_name}:未收到安衡电子秤特征数据,判定非电子秤")
+            try:
+                ser.close()
+            except Exception:
+                pass
+            return False
+
+        # 确认是电子秤,接管串口并启动后台读取线程
+        self.ser = ser
+        self._buf = collected
+        self._last_data_time = time.time()
+        self._running = True
+        self._should_run = True  # 连接即视为"应连接",断开后可自动重连
+        self._device_lost = False  # 连接成功,清除丢失标记
+        self._read_thread = threading.Thread(target=self._background_read_loop, daemon=True)
+        self._read_thread.start()
+        return True
+
+    @staticmethod
+    def _is_anheng_data(data: bytes) -> bool:
+        """判断数据是否符合安衡协议特征:状态位(ST/US) + 模式(TR/NT) + 重量值"""
+        if not data:
+            return False
+        for line in data.split(b"\n"):
+            text = line.decode("ascii", errors="ignore").strip()
+            if not text:
+                continue
+            parts = [p.strip() for p in text.split(",")]
+            has_status = any(p in ("ST", "US") for p in parts)
+            has_mode = any(p in ("TR", "NT") for p in parts)
+            has_weight = any(AnhengScale.WEIGHT_RE.fullmatch(p) for p in parts)
+            if has_status and has_mode and has_weight:
+                return True
+        return False
+
+    def _close_serial_link(self):
+        """仅关闭串口链路(停止读取线程+关闭串口),不改变 _should_run/_manually_closed。
+        供内部在"断开/重连前"复用,避免误触发整体停止。"""
+        self._running = False
+        rt = self._read_thread
+        # 读取线程自己触发时不能 join 自身,否则死锁
+        if rt is not None and rt is not threading.current_thread() and rt.is_alive():
+            rt.join(timeout=1.0)
+        self._read_thread = None
+        if self.ser is not None:
+            try:
+                self.ser.close()
+            except Exception:
+                pass
+            self.ser = None
+
+    def disconnect(self):
+        """显式关闭:停止后台读取与自动重连(不再自动重连,除非重新 connect/probe_connect)"""
+        self._manually_closed = True
+        self._should_run = False
+        self._device_lost = False
+        self._push_status("closed")
+        self._close_serial_link()
+
+    def is_connected(self) -> bool:
+        return self.ser is not None and self.ser.is_open
+
+    def _background_read_loop(self):
+        """后台循环读取串口数据;检测读取异常/数据超时 => 判定设备断开并触发重连"""
+        consecutive_errors = 0
+        while self._running:
+            ser = self.ser
+            if ser is None or not ser.is_open:
+                break
+            try:
+                wait_cnt = ser.in_waiting
+                if wait_cnt > 0:
+                    chunk = ser.read(wait_cnt)
+                    if chunk:
+                        self._buf += chunk
+                        self._parse_buffer()
+                        self._last_data_time = time.time()
+                    consecutive_errors = 0
+                elif self.data_timeout and (time.time() - self._last_data_time) > self.data_timeout:
+                    # 看门狗:连续输出模式下一段时间无任何数据,判定设备失联
+                    self._mark_device_lost("数据超时,判定设备断开")
+                    break
+            except Exception as e:
+                consecutive_errors += 1
+                print(f"电子秤读取异常({consecutive_errors}): {e}")
+                # 设备拔出后串口读取会抛异常;串口已关闭或连续多次异常 => 判定断开
+                if not ser.is_open or consecutive_errors >= self.READ_ERROR_THRESHOLD:
+                    self._mark_device_lost(f"读取异常: {e}")
+                    break
+                time.sleep(0.05)
+            time.sleep(0.01)  # 10ms轮询,和原GUI逻辑对齐
+
+    def _parse_buffer(self):
+        """解析缓冲区,按\n分割每行,解析协议"""
+        while b"\n" in self._buf:
+            line_bytes, self._buf = self._buf.split(b"\n", 1)
+            self._parse_one_line(line_bytes)
+
+    def _parse_one_line(self, line: bytes):
+        try:
+            text = line.decode("ascii", errors="ignore").strip()
+        except Exception:
+            return
+        if not text:
+            return
+
+        parts = [p.strip() for p in text.split(",")]
+        stable = None
+        mode = None
+
+        if "ST" in parts:
+            stable = True
+        elif "US" in parts:
+            stable = False
+
+        if "TR" in parts:
+            mode = "TR"
+        elif "NT" in parts:
+            mode = "NT"
+
+        if stable is not None:
+            self.latest_data["stable"] = stable
+        if mode is not None:
+            self.latest_data["mode"] = mode
+
+        # 提取重量
+        for seg in parts:
+            match = self.WEIGHT_RE.fullmatch(seg)
+            if not match:
+                continue
+            val_str = match.group(1).replace(" ", "")
+            val = float(val_str)
+            unit = match.group(2).lower()
+            raw_g = val * 1000.0 if unit == "kg" else val
+            self.latest_data["raw_grams"] = raw_g
+            # 软件去皮计算
+            self.latest_data["net_grams"] = raw_g - self.tare_grams
+            return
+
+    def get_latest(self) -> Dict[str, Any]:
+        """
+        获取最新读数
+        返回:
+        {
+            "stable": True/False/None,
+            "mode": "TR"/"NT"/None,
+            "raw_grams": float,    # 秤原始重量g
+            "net_grams": float     # 软件去皮之后净重g
+        }
+        """
+        return self.latest_data.copy()
+
+    async def send_data_message(self, data: Dict[str, Any]):
+        """
+        推送电子秤数据消息到前端(与 BaseClass.sendSocketMessage 一致,走全局消息队列)
+        :param data: get_latest() 返回的数据
+        """
+        if self.websocket_manager is None:
+            return
+        payload = {
+            "code": 0,
+            "msg": "安衡电子秤数据",
+            "status": 2,
+            "data": data,
+            "msg_type": "anheng_scale",
+        }
+        await message_queue.put(payload)
+
+    def _push_status(self, status: str, reason: str = ""):
+        """
+        通过全局消息队列向前端推送连接状态(前端可选消费,msg_type=anheng_scale_status)。
+        可从后台线程安全调用(把 put 协程调度到主事件循环执行)。
+        status: connected / disconnected / reconnecting / reconnected / closed
+        """
+        if self.websocket_manager is None:
+            return
+        status_text = {
+            "connected": "电子秤已连接",
+            "disconnected": "电子秤已断开",
+            "reconnecting": "电子秤重连中",
+            "reconnected": "电子秤重连成功",
+            "closed": "电子秤已关闭",
+        }.get(status, "电子秤状态")
+        payload = {
+            "code": 1 if status in ["disconnected", "reconnecting", 'closed'] else 0,
+            "msg": status_text if not reason else f"{status_text}:{reason}",
+            "status": -1 if status in ["disconnected", "reconnecting", 'closed'] else 2,
+            "data": {"status": status, "port": self._port_name, "reason": reason},
+            "msg_type": "anheng_scale_status",
+        }
+        try:
+            if self._loop is not None:
+                asyncio.run_coroutine_threadsafe(message_queue.put(payload), self._loop)
+        except Exception as e:
+            print(f"推送电子秤状态失败: {e}")
+
+    def start_run(self):
+        """启动数据推送循环(参考 LineControl 模式:create_task(run())),防止重复创建"""
+        if self._pushing:
+            return
+        self._pushing = True
+        self._should_run = True
+        self._manually_closed = False
+        self._capture_loop()
+        try:
+            if self._loop is None:
+                self._loop = asyncio.get_event_loop()
+            self._loop.create_task(self.run())
+        except Exception as e:
+            print(f"启动电子秤推送循环失败: {e}")
+            self._pushing = False
+            return
+        self._push_status("connected")
+
+    async def run(self):
+        """电子秤数据推送循环(参考 LineControl.run 模式)。
+        断开期间不推送读数但不退出;自动重连成功后自动恢复,无需重启本循环。"""
+        while self._should_run:
+            await asyncio.sleep(0.06)
+            if not self.is_connected():
+                continue
+            data = self.get_latest()
+            if self.is_send_message:
+                await self.send_data_message(data)
+                self.is_send_message = False
+        self._pushing = False
+
+    # ===== 实时连接检测 + 自动重连 核心 =====
+    def _mark_device_lost(self, reason: str):
+        """检测到设备断开(幂等)。关闭串口链路,推送状态并拉起自动重连。"""
+        if self._device_lost:
+            return
+        self._device_lost = True
+        print(f"安衡电子秤断开:{reason}")
+        self._push_status("disconnected", reason)
+        self._close_serial_link()
+        if self._auto_reconnect and not self._manually_closed and self._should_run:
+            self._ensure_reconnect_thread()
+
+    def _ensure_reconnect_thread(self):
+        """确保自动重连线程存活(已存活则不重复创建)"""
+        if self._reconnecting:
+            return
+        self._reconnecting = True
+        self._reconnect_thread = threading.Thread(target=self._reconnect_loop, daemon=True)
+        self._reconnect_thread.start()
+
+    def _interruptible_sleep(self, seconds: float):
+        """可被显式关闭打断的休眠,避免手动 disconnect 后线程长时间卡住"""
+        end = time.time() + seconds
+        while time.time() < end and self._should_run and not self._manually_closed:
+            time.sleep(0.2)
+
+    def _find_scale_port(self):
+        """重新扫描串口,返回安衡电子秤候选串口号(兼容重插后COM号变化)。
+        优先上次记录的COM口,否则取第一个 Prolific 串口;找不到返回 None。"""
+        try:
+            ports = list(serial.tools.list_ports.comports())
+        except Exception as e:
+            print(f"扫描串口失败: {e}")
+            return None
+        if self._port_name:
+            for p in ports:
+                if p.device == self._port_name and self.SCALE_DESC_KEYWORD in (p.description or ""):
+                    return p.device
+        for p in ports:
+            if self.SCALE_DESC_KEYWORD in (p.description or ""):
+                return p.device
+        return None
+
+    def _try_reconnect(self) -> bool:
+        """尝试重连一次:重新扫描定位电子秤 => 严格探测确认 => 打开并启动读取线程"""
+        port = self._find_scale_port()
+        if not port:
+            print("安衡电子秤重连:未发现候选串口")
+            return False
+        print(f"安衡电子秤重连:尝试连接 {port}")
+        return self.probe_connect(port, verify=True)
+
+    def _reconnect_loop(self):
+        """自动重连主循环:设备丢失 => 指数退避重试(重新扫描+探测) => 成功后进入稳定观察,
+        直到被显式 disconnect() 停止。"""
+        backoff = self.RECONNECT_INITIAL_DELAY
+        announced = False
+        while self._should_run and not self._manually_closed:
+            if not self._device_lost and self.is_connected():
+                # 健康状态:复位退避与提示标记,等待下一次丢失
+                backoff = self.RECONNECT_INITIAL_DELAY
+                announced = False
+                self._interruptible_sleep(0.3)
+                continue
+            if not announced:
+                self._push_status("reconnecting")
+                announced = True
+            if self._try_reconnect():
+                self._last_data_time = time.time()
+                self._push_status("reconnected")
+                backoff = self.RECONNECT_INITIAL_DELAY
+                self._interruptible_sleep(self.RECONNECT_STABLE_WAIT)
+                continue
+            self._interruptible_sleep(backoff)
+            backoff = min(backoff * 2, self.RECONNECT_MAX_DELAY)
+        self._reconnecting = False
+        self._reconnect_thread = None
+        # 退出时兜底关闭串口链路,处理"重连探测与手动关闭竞态"可能残留的串口/读取线程
+        self._close_serial_link()
+
+    def software_tare(self) -> bool:
+        """
+        【软件本地去皮,不发送指令给电子秤硬件】
+        只有秤稳定时才能执行,记录当前原始重量作为皮重
+        :return: True成功;False失败(未稳定)
+        """
+        if self.latest_data.get("stable") is not True:
+            return False
+        self.tare_grams = self.latest_data["raw_grams"]
+        self.latest_data["net_grams"] = self.latest_data["raw_grams"] - self.tare_grams
+        return True
+
+    def clear_software_tare(self):
+        """清除软件去皮,皮重置0"""
+        self.tare_grams = 0.0
+        self.latest_data["net_grams"] = self.latest_data["raw_grams"]
+
+    def send_hardware_cmd(self, cmd: str):
+        """底层:向串口发送原始指令,需要秤设置COMMOD=5问答模式才生效"""
+        if not self.is_connected():
+            return False
+        try:
+            self.ser.write(cmd.encode("ascii"))
+            return True
+        except Exception as e:
+            print(f"发送指令失败 {e}")
+            return False
+
+    def hardware_tare(self):
+        """【硬件去皮】发送指令让电子秤本体执行去皮,秤必须开启问答模式COMMOD=5"""
+        return self.send_hardware_cmd(self.CMD_TARE)
+
+    def hardware_zero(self):
+        """【硬件归零】发送指令让电子秤本体归零,秤必须开启问答模式COMMOD=5"""
+        return self.send_hardware_cmd(self.CMD_ZERO)

+ 20 - 16
python/mcu/capture/smart_shooter_class.py

@@ -13,6 +13,7 @@ import logging
 from databases import SqlQuery, CRUD, SysConfigs
 from utils.common import message_queue
 from models import SysConfigParams
+
 logger = logging.getLogger(__name__)
 
 
@@ -122,7 +123,8 @@ class SmartShooter(metaclass=SingletonType):
             context.term()
             msg_send = "相机未连接或软件未打开"
             return False, msg_send
-    def get_iso_range(self,camera_info):
+
+    def get_iso_range(self, camera_info):
         """
         从相机数据中获取 ISO 的可调范围
         :param camera_data: 相机状态数据列表 (即你提供的那个长列表)
@@ -138,6 +140,7 @@ class SmartShooter(metaclass=SingletonType):
                 return prop.get('CameraPropertyRange')
 
         return None
+
     async def GetCameraInfo(self, is_send=True, msg_type="", CameraKey=None):
         await asyncio.sleep(0.001)
         # self.msg_type = msg_type
@@ -158,13 +161,13 @@ class SmartShooter(metaclass=SingletonType):
             msg_result = json_msg.get("msg_result")
             if not msg_result:
                 self.connect_status = False
-                msg_send =  f"请检查{CameraKey},相机是否连接" if CameraKey else f"请检查相机是否连接"
+                msg_send = f"请检查{CameraKey},相机是否连接" if CameraKey else f"请检查相机是否连接"
                 if is_send:
                     message = {
                         "code": 1,
                         "msg": msg_send,
                         "data": msg_result,
-                        "CameraKey":CameraKey,
+                        "CameraKey": CameraKey,
                         "msg_type": msg_type,
                         "device_status": -1,
                     }
@@ -215,7 +218,7 @@ class SmartShooter(metaclass=SingletonType):
                         "code": 1,
                         "msg": msg_send,
                         "data": msg_result,
-                        "CameraKey":CameraKey,
+                        "CameraKey": CameraKey,
                         "msg_type": msg_type,
                         "device_status": -1,
                     }
@@ -229,7 +232,7 @@ class SmartShooter(metaclass=SingletonType):
                     "code": 0,
                     "msg": msg_send,
                     "data": msg_result,
-                    "CameraLists":CameraLists,
+                    "CameraLists": CameraLists,
                     "msg_type": msg_type,
                     "device_status": 2,
                 }
@@ -243,8 +246,8 @@ class SmartShooter(metaclass=SingletonType):
             self.connect_status = False
             socket.close()
             context.term()
-            print("获取相机信息超时,继续监听...",e)
-            msg_send =  f"请检查{CameraKey},相机是否连接" if CameraKey else f"请检查相机是否连接"
+            print("获取相机信息超时,继续监听...", e)
+            msg_send = f"请检查{CameraKey},相机是否连接" if CameraKey else f"请检查相机是否连接"
             return False, msg_send
         except Exception as e:
             print("相机状态获取异常", e)
@@ -349,7 +352,7 @@ class SmartShooter(metaclass=SingletonType):
         high_iso = Itemiso.get("high", 6400)
         return low_iso, high_iso
 
-    def initConfigIsoSettings(self, CameraLists=[],isMultCameraMode=False):
+    def initConfigIsoSettings(self, CameraLists=[], isMultCameraMode=False):
         if not CameraLists:
             return None
         """获取ISO配置信息"""
@@ -367,7 +370,7 @@ class SmartShooter(metaclass=SingletonType):
                 print("相机无变动。无需自动更改配置")
                 return None
             basic_iso = temp_A_point.get("iso", {"low": 100, "high": 6400})
-            print("isMultCameraMode======>>>>",isMultCameraMode)
+            print("isMultCameraMode======>>>>", isMultCameraMode)
             if not isMultCameraMode:
                 points = {"A": {}}
                 print("相机发生变动。需要更改配置")
@@ -384,10 +387,10 @@ class SmartShooter(metaclass=SingletonType):
                 settings.sync_sys_configs2Online()
             return None
         points = {"A": {}, "B": {}, "C": {}}
-        for idx,item in enumerate(points):
+        for idx, item in enumerate(points):
             low_iso = camera_configs.get("low", 100)
             high_iso = camera_configs.get("high", 6400)
-            if idx > len(CameraLists) -1:
+            if idx > len(CameraLists) - 1:
                 points[item] = {
                     "iso": {"low": low_iso, "high": high_iso},
                 }
@@ -400,8 +403,9 @@ class SmartShooter(metaclass=SingletonType):
         settings.updateSysConfigs(params=sys_iso_config)
         # 同步本地到线上
         settings.sync_sys_configs2Online()
+
     async def EnableCameraPreview(
-        self, enable_status=True, msg_type="", CameraKey=None,isMultCameraMode=False
+            self, enable_status=True, msg_type="", CameraKey=None, isMultCameraMode=False
     ):
         print("收到得msg_type====>>>", msg_type)
         self.msg_type = msg_type
@@ -429,14 +433,14 @@ class SmartShooter(metaclass=SingletonType):
             # 等于auto就不设置
             if enable_status == True:
                 if str(high_iso).lower() != "auto":
-                    set_state,set_msg = await self.setCameraProperty(
+                    set_state, set_msg = await self.setCameraProperty(
                         property="ISO", value=str(high_iso), CameraKey=CameraKey
                     )
                 else:
                     print("high_iso 等于auto就不设置")
             if enable_status == False:
                 if str(low_iso).lower() != "auto":
-                    set_state,set_msg = await self.setCameraProperty(
+                    set_state, set_msg = await self.setCameraProperty(
                         property="ISO", value=str(low_iso), CameraKey=CameraKey
                     )
                 else:
@@ -532,7 +536,7 @@ class SmartShooter(metaclass=SingletonType):
             return False, "相机未连接或软件未打开"
 
     async def CameraShooter(
-        self, msg_type="", goods_art_no="", id=0, is_af=False, delay=0.5, CameraKey=None
+            self, msg_type="", goods_art_no="", id=0, is_af=False, delay=0.5, CameraKey=None
     ):
         # 这里延迟一秒钟 等待前置命令完成
         await asyncio.sleep(delay)
@@ -622,7 +626,7 @@ class SmartShooter(metaclass=SingletonType):
             # await self.sendMessageSocket(message)
             return True, "拍照失败"
         except Exception as e:
-            print("拍照出错",e)
+            print("拍照出错", e)
             socket.close()
             context.term()
             msg_send = "拍照失败"

+ 4 - 1
python/models.py

@@ -116,7 +116,10 @@ class HandlerDetail(BaseModel):
         default=0, description="是否生成上脚图;0否;1是"
     )
     product_scene_prompt: Optional[str] = Field(
-        default=[], description="上传的店铺,数组形式"
+        default="", description="场景图提示词"
+    )
+    product_scene_image: Optional[str] = Field(
+        default="", description="场景图参考图"
     )
     upper_footer_params: Optional[dict] = Field(
         default=None, description="上脚图参数配置"

+ 1 - 21
python/service/grenerate_main_image_test.py

@@ -819,6 +819,7 @@ class GeneratePic(object):
                 backdrop_prepped, source_prepped, opacity=opacity_params / 100
             )
             im_shadow = Image.fromarray(np.uint8(blended_np)).convert("RGB")
+        # 边缘羽化处理
         im_shadow = self.filter_2(im_shadow=im_shadow, feather=100)
         # 把原图粘贴回去,避免色差
         im_shadow.paste(cut_image, (0, 0), mask=cut_image)
@@ -1035,24 +1036,3 @@ class GeneratePic(object):
         else:
             scale = box_height / height
         return scale
-
-    def filter_2(self, im_shadow, feather=100):
-        # feather 羽化宽度,400 图建议 40~120,越大越柔和
-        background = Image.new(mode="RGB", size=im_shadow.size, color=(255, 255, 255))
-        # 遮罩边缘做羽化处理,设计一个蒙版
-        w, h = im_shadow.size
-        mask = Image.new(mode="L", size=im_shadow.size, color=0)
-        draw = ImageDraw.Draw(mask)
-
-        # x0, y0:矩形左上角坐标
-        # x1, y1:矩形右下角坐标
-        draw.rectangle([feather, feather, w - feather, h - feather], fill=255)
-
-        # 高斯模糊,让边缘从白到黑渐变
-        mask = mask.filter(ImageFilter.GaussianBlur(feather / 2))
-        # if self.is_test:
-        #     mask.show()
-        background.paste(im=im_shadow, mask=mask)
-        im_shadow = background
-
-        return im_shadow

+ 25 - 23
python/service/online_request/module_online_data.py

@@ -9,6 +9,7 @@ from PIL import Image
 import io, time
 from logger import logger
 
+
 # logger = logging.getLogger(__name__)
 
 class JsonEncoder(json.JSONEncoder):
@@ -38,7 +39,7 @@ def download_image_with_pil(url, save_path):
         return save_path
 
     except Exception as e:
-        print("保存错误",e)
+        print("保存错误", e)
         return "error"
 
 
@@ -60,7 +61,7 @@ class AIGCDataRequest(object):
         return resultData["data"]["url"]
 
     def center_paste_image(
-        self, source_image_path, output_path, width=900, ratio=(3, 4)
+            self, source_image_path, output_path, width=900, ratio=(3, 4)
     ):
         """
         将一张PNG透明图片居中粘贴到指定尺寸和比例的背景上,保持透明度
@@ -115,20 +116,21 @@ class AIGCDataRequest(object):
         source_img.close()
         return background
 
-    def generateProductScene(self, local_path, prompt, save_path):
+    def generateProductScene(self, local_path, prompt, save_path, product_scene_image):
         imageUrl = self.uploadImage(local_path)
         print("imageUrl", imageUrl)
         data = {
             "site": 1,
             "base_image": imageUrl,
             "keyword": prompt,
+            "reference_image": product_scene_image,
             "model_type": 1,
             "gemini_model": "gemini-2.5-flash-image-preview",
         }
         """生成场景图"""
         try:
             url = settings.DOMAIN + "/api/ai_image/inspired/command_to_image"
-            resultData = self.s.post(url, data=data, headers=self.post_headers, timeout=80).json()
+            resultData = self.s.post(url, data=data, headers=self.post_headers, timeout=120).json()
 
             code = resultData.get("code", 0)
             message = resultData.get("message", "")
@@ -141,7 +143,7 @@ class AIGCDataRequest(object):
             save_image_path = download_image_with_pil(image_url, save_path)
             return save_image_path
         except:
-          raise UnicornException("场景图生成失败")
+            raise UnicornException("场景图生成失败")
 
     def generateProductSceneQW(self, local_path, prompt, save_path):
         '''千问生成场景图'''
@@ -208,13 +210,13 @@ class AIGCDataRequest(object):
             data_item = data_result[0]
             status = data_item.get("status", -1)
             result_image = None
-            
+
             # 只有在完成状态下才返回图片URL
             if status == 2:  # 完成
                 result_image_urls = data_item.get("result_image_urls", [])
                 result_image_urls = [] if result_image_urls is None else result_image_urls
                 result_image = result_image_urls[0] if len(result_image_urls) > 0 else None
-                
+
             return status, result_image
         except requests.Timeout:
             print("查询进度超时")
@@ -247,7 +249,7 @@ class AIGCDataRequest(object):
         if len(generate_ids) == 0:
             raise UnicornException("模特图生成失败")
         generate_id = generate_ids[0]
-        search_times = 80
+        search_times = 120
         status = 0
         result_image = None
         print("generate_id", generate_id)
@@ -267,7 +269,7 @@ class AIGCDataRequest(object):
         if status == -1 or (status != 2 and search_times <= 0):
             raise UnicornException("模特图生成失败")
         save_image_path = download_image_with_pil(result_image, save_path)
-        print("上脚图save_image_path",result_image, save_image_path)
+        print("上脚图save_image_path", result_image, save_image_path)
         return save_image_path
 
 
@@ -587,7 +589,7 @@ class OnlineDataRequest(object):
         print("上传商品api==>resultData", resultData)
         return resultData
 
-    def sendSocketMessage(self, code=0, msg="", data=None, device_status=2,msg_type="upload_goods_progress"):
+    def sendSocketMessage(self, code=0, msg="", data=None, device_status=2, msg_type="upload_goods_progress"):
         data = {
             "code": code,
             "msg": msg,
@@ -636,12 +638,12 @@ class OnlineDataRequest(object):
                     mainImagePath = mainImages[0]
                     imageUrl = self.uploadImage(local_path=mainImagePath)
                     skuNameJson = []
-                    skuNameJson.append({"propName":"颜色","propValue":color_name})
-                    skuNameJson.append({"propName":"尺寸","propValue":size})
+                    skuNameJson.append({"propName": "颜色", "propValue": color_name})
+                    skuNameJson.append({"propName": "尺寸", "propValue": size})
                     skuItemData = {
                         "颜色": color_name,
                         "尺寸": size,
-                        "skuPropName":color_name,
+                        "skuPropName": color_name,
                         "skuNo": sku_goods_art_no,
                         "originalPrice": float(goods_price),
                         "newSkuWeight": int(1),
@@ -650,7 +652,7 @@ class OnlineDataRequest(object):
                         "sellingPrice": float(goods_price),
                         "quantity": int(quantity),
                         "showOrder": int(skuIdx + 1),
-                        "skuNameJson":skuNameJson
+                        "skuNameJson": skuNameJson
                     }
                     skuList.append(skuItemData)
                     itemImage = {
@@ -681,16 +683,16 @@ class OnlineDataRequest(object):
                     }
                 )
                 itemSkuImageList.append({
-                        "propName": "尺寸",
-                        "isImageProp": 0,
-                        "propShowOrder": 1,
-                        "showOrder": 0,
+                    "propName": "尺寸",
+                    "isImageProp": 0,
+                    "propShowOrder": 1,
+                    "showOrder": 0,
+                    "propValue": str(size),
+                    "skuPropValueList": [{
                         "propValue": str(size),
-                        "skuPropValueList":[{
-                            "propValue": str(size),
-                            "showOrder": 1,
-                            }]
-                    })
+                        "showOrder": 1,
+                    }]
+                })
                 detailImageUrl = self.uploadImage(local_path=detail_path)
                 category_info = "流行男鞋>>休闲鞋>>时尚休闲鞋"
                 itemData = {

+ 35 - 10
python/service/shoe_main_image_compose.py

@@ -90,7 +90,7 @@ def _detect_band_widths(canvas, band_color=None, tolerance=30):
     band_color 为 None 时,从模板左上角采样取中值作为基准色。
     """
     a = np.asarray(canvas)
-    rgb = a[:, :, :3].astype(float)                 # 只用 RGB 通道做颜色判定
+    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:
@@ -240,11 +240,14 @@ def compose_shoe_main_image(
         cell_pad=4,  # 每格上下留白
         shoe_width_limit_ratio=1.15,  # 鞋子最大宽度 = 该格浅色区域宽度 * 比例(可略溢出)
         shoe_scale=1.0,  # 鞋子最终尺寸缩放系数:1.0=自动适配大小,>1 放大,<1 缩小
-        label_pos="bottom",  # 颜色文本位置:"bottom"下方 | "right"右侧(图1样式)
+        # 颜色文本位置:下方组 bottom(居中)/bottom-left(左对齐)/bottom-right(右对齐);
+        #              右侧组 right(居中)/right-top(顶对齐)/right-bottom(底对齐)
+        label_pos="bottom",
         label_font_size=15,
         label_color=(60, 60, 60),
         label_dot=True,  # 颜色文本前是否画小圆点(取鞋子主色)
         label_gap=5,  # 鞋与颜色文本的间距
+        label_offset=(0, 0),  # 颜色文本偏移量 (dx, dy):dx 左右(>0 右移),dy 上下(>0 下移)
         shoe_center_offset=0,  # 鞋子中心相对浅色区域中心的水平偏移
         # ---- C区域:底部文本信息 ----
         info_bar=True,  # True=画圆角信息条;False=只画文字
@@ -415,6 +418,13 @@ def compose_shoe_main_image(
                 .format(area_top, area_bottom))
         cell_h = (area_bottom - area_top) / n
         lab_font = _load_font(font_path, label_font_size)
+        # 颜色文本偏移量 (dx, dy):dx 左右(>0 右移),dy 上下(>0 下移)
+        try:
+            label_dx, label_dy = int(label_offset[0]), int(label_offset[1])
+        except (TypeError, ValueError, IndexError):
+            label_dx, label_dy = 0, 0
+        # 文本位置分组:below 组在鞋下方(bottom/bottom-left/bottom-right),其余在鞋右侧
+        below = label_pos in ("bottom", "bottom-left", "bottom-right")
 
         for i, item in enumerate(shoes):
             if isinstance(item, str):
@@ -422,6 +432,7 @@ def compose_shoe_main_image(
             else:
                 shoe_src = item.get("image")
                 color_name = str(item.get("color", ""))
+            # print("shoe_src", shoe_src)
             shoe_im = _trim_alpha(_to_image(shoe_src).convert("RGBA"))
             sw0, sh0 = shoe_im.size
 
@@ -442,7 +453,7 @@ def compose_shoe_main_image(
 
             # 该格内鞋子可占用的最大盒子:已扣除留白与颜色文本,
             # 保证"鞋 + 颜色名称"整体都落在浅色区内、绝不超出。
-            if label_pos == "bottom":
+            if below:
                 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:鞋在左、文本紧跟其右,两者都要在带内
@@ -457,7 +468,7 @@ def compose_shoe_main_image(
             if (sw, sh) != (sw0, sh0):
                 shoe_im = shoe_im.resize((sw, sh), Image.LANCZOS)
 
-            if label_pos == "bottom":
+            if below:
                 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))
@@ -472,13 +483,27 @@ def compose_shoe_main_image(
 
             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")
+                if below:
+                    # 鞋下方:垂直在鞋底+间距;水平按 bottom / bottom-left / bottom-right 对齐
+                    ly = sy + sh + int(label_gap) + label_dy
+                    if label_pos == "bottom-right":      # 右对齐:文本右缘贴鞋右缘
+                        lx, align = int(sx + sw - label_w) + label_dx, "left"
+                    elif label_pos == "bottom-left":     # 左对齐:文本左缘贴鞋左缘
+                        lx, align = int(sx) + label_dx, "left"
+                    else:                                 # bottom:水平居中
+                        lx, align = int(sx + sw / 2) + label_dx, "center"
+                    _draw_color_label(draw, lab_font, color_name, lx, ly,
+                                      label_color, dot=dot_color, align=align)
                 else:
-                    ly = sy + (sh - label_h) // 2
-                    _draw_color_label(draw, lab_font, color_name, label_x, ly,
+                    # 鞋右侧:水平在鞋右+间距;垂直按 right / right-top / right-bottom 对齐
+                    lx = int(label_x) + label_dx
+                    if label_pos == "right-top":         # 顶对齐:文本顶贴鞋顶
+                        ly = sy + label_dy
+                    elif label_pos == "right-bottom":    # 底对齐:文本底贴鞋底
+                        ly = sy + sh - label_h + label_dy
+                    else:                                 # right:垂直居中
+                        ly = sy + (sh - label_h) // 2 + label_dy
+                    _draw_color_label(draw, lab_font, color_name, lx, ly,
                                       label_color, dot=dot_color, align="left")
 
     # ---------- 调试叠加图 ----------

+ 13 - 0
python/sockets/message_handler.py

@@ -870,6 +870,19 @@ async def handlerSend(
                 websocket_manager=manager, smart_shooter=smart_shooter
             )
             device_ctrl.one_key_turnoff("mcu", False)
+        case "get_scale_data":
+            '''获取安衡电子秤数据'''
+            device_ctrl = DeviceControl(
+                websocket_manager=manager, smart_shooter=smart_shooter
+            )
+            if device_ctrl.anheng_scale._running == False:
+                data = manager.jsonMessage(
+                    code=1,
+                    msg=f"记录不存在,请核实后重新操作~",
+                    msg_type="anheng_scale_status",
+                )
+                await manager.send_personal_message(data, websocket)
+            device_ctrl.anheng_scale.is_send_message = True
         case "delete_photo_record":  # 重拍
             msg_type = "delete_photo_record"
             record_id = data.get("record_id")

+ 1 - 0
python/sockets/socket_server.py

@@ -284,6 +284,7 @@ async def shutdown_event():
     smart_shooter.is_init_while = False
     device_ctrl.close_connect()
     device_ctrl.close_lineConnect()
+    device_ctrl.anheng_scale.close_anheng_scale()
     device_ctrl.mcu_exit = True
     device_ctrl.p_list = []
     device_ctrl.temp_ports_dict = {}

+ 40 - 28
python/temp.py

@@ -1,29 +1,41 @@
-import zmq, json
-# def __send_tcp_message(socket, msg):
-#     socket.send_string(json.dumps(msg, ensure_ascii=False))
-#     rep = socket.recv()
-#     str_msg = rep.decode("utf-8")
-#     json_msg = json.loads(str_msg)
-#     return json_msg
-# LISTEN_REQ = "tcp://127.0.0.1:54543"
-# SET_REQ = "tcp://127.0.0.1:54544"
-# context = zmq.Context()
-# req_socket = context.socket(zmq.REQ)
-# # 设置发送超时为 5000 毫秒(5 秒)
-# req_socket.setsockopt(zmq.RCVTIMEO, 2 * 1000)
-# # 设置接收超时为 5000 毫秒(5 秒)
-# req_socket.setsockopt(zmq.SNDTIMEO, 2 * 1000)
-# req_socket.setsockopt(zmq.LINGER, 0)  # 设置为 0 表示不等待未完成的操作
-# req_socket.connect(SET_REQ)
-# req = {}
-# req["msg_type"] = "Request"
-# req["msg_id"] = "GetCamera"
-# req["msg_seq_num"] = 0
-# req["CameraSelection"] = "Single"
-# req["CameraKey"] = "Canon Inc.|Canon EOS 650D|12"
-# json_msg = __send_tcp_message(req_socket,req)
-# cameraInfo = json_msg.get("CameraInfo")
-# print("cameraInfo",json_msg)
-from service.online_request.ding_robot import sendDingMsg
+from mcu.anheng_scale import AnhengScale
+import time
 
-sendDingMsg("抠图异常", "抠图异常!!!")
+if __name__ == "__main__":
+    scale = AnhengScale(baudrate=9600)
+
+    # 1.列出所有串口,找到你的COM口
+    ports = scale.get_all_com_ports()
+    print("可用串口列表:")
+    for item in ports:
+        print("item info ", item)
+        print(f"device:{item['device']}  desc:{item['desc']}")
+    if not ports:
+        print("没有找到可用串口")
+        exit()
+    # 2.填入你的串口,例如 COM3
+    com_port = "COM3"
+    ok = scale.connect(com_port)
+    if not ok:
+        print("串口打开失败,请检查接线与COM号")
+    else:
+        print(f"已连接 {com_port}")
+        try:
+            while True:
+                data = scale.get_latest()
+                print(
+                    f"稳定:{data['stable']} |模式:{data['mode']} |原始g:{data['raw_grams']:.2f} |去皮后g:{data['net_grams']:.2f}")
+
+                # 示例:按条件软件去皮(需要秤稳定)
+                # res = scale.software_tare()
+                # if res:
+                #     print("软件去皮成功")
+
+                # scale.hardware_tare() #硬件去皮,秤要设COMMOD=5
+                # scale.hardware_zero() #硬件归零
+
+                time.sleep(0.5)
+        except KeyboardInterrupt:
+            print("程序退出")
+        finally:
+            scale.disconnect()