Parcourir la source

feat(mcu): 新增设备一键下发命令功能

- 新增OneDataSet单例类,支持JSON和HEX两种格式命令下发
- DeviceControl新增send_json_to和send_hex_cmd_to方法
- WebSocket消息处理新增get_one_data_set和send_get_one_data_set接口
- 新增command.txt常用MCU命令配置文件
- 相机拍照参数支持JSON字符串自动归一化为字典
- 修复串口未连接时发送命令的异常处理
- 修复socket_server中anheng_scale关闭方法调用错误
- 新增抠图调用流程说明文档
rambo il y a 22 heures
Parent
commit
b64b901739

+ 552 - 0
doc/抠图调用流程说明.md

@@ -0,0 +1,552 @@
+# 抠图调用流程说明文档
+
+> 代码入口:`python/service/base_deal.py` L663-687
+> 核心实现:`python/service/remove_bg_ali.py`
+> 上传接口封装:`python/service/image_deal_base_func.py`、`python/api.py`
+
+---
+
+## 一、抠图模式总览
+
+系统配置 `other_configs.cutout_mode`(读取方式:`settings.getSysConfigs("other_configs", "cutout_mode", "普通抠图")`)先被映射为模式字符串 `"1"~"5"`(见 `python/api.py` L498-511),再传入 `base_deal.py` 分发:
+
+| 模式值 | 模式名称 | 调用类 | 调用方法 | 后端类型 |
+|---|---|---|---|---|
+| `"1"` | 普通抠图 | `RemoveBgALi` | `get_image_cut()` | 阿里云视觉智能 SDK |
+| `"2"` | 精细化抠图 | `DealCutout`(内部 `DealOneImageBeforehand` + `DealOneImage`) | `run()` | 阿里 SDK 预抠图 + Pixian 第三方接口 二次抠图 |
+| `"3"` | 旗舰AI抠图 | `RemoveUltraBackground` | `ultra_segment_fast()` | 自建平台 HTTP 接口 |
+| `"4"` | 旗舰AI抠图-精细 | `RemoveUltraBackground` | `run_ultra_segment()` | 阿里 SDK 预抠图 + 自建平台接口 二次抠图 |
+| `"5"` | 旗舰AI抠图-透明物品 | `RemoveBgGlasses` | `glasses_segment()` | 自建平台 HTTP 接口 |
+
+调用分发代码(`base_deal.py` L663-687):
+
+```python
+if cutout_mode == "1":
+    remove_pic_ins = RemoveBgALi()
+    im = remove_pic_ins.get_image_cut(
+        file_path=original_image_path,
+        out_file_path=original_move_bg_image_path,
+    )
+if cutout_mode == "3":
+    remove_pic_ins = RemoveUltraBackground()
+    im = remove_pic_ins.ultra_segment_fast(
+        file_path=original_image_path,
+        out_file_path=original_move_bg_image_path,
+    )
+if cutout_mode == "4":
+    remove_pic_ins = RemoveUltraBackground()
+    im = remove_pic_ins.run_ultra_segment(
+        file_path=original_image_path,
+        out_file_path=original_move_bg_image_path,
+    )
+if cutout_mode == "5":
+    remove_pic_ins = RemoveBgGlasses()
+    im = remove_pic_ins.glasses_segment(
+        file_path=original_image_path,
+        out_file_path=original_move_bg_image_path,
+    )
+```
+
+所有方法签名一致:入参 `file_path`(原图路径,必须是 RGB/JPG,PNG 直接返回 None)、`out_file_path`(抠图结果输出 PNG 路径);返回 PIL `Image` 对象(RGBA),失败返回 `None`。方法均带 `@func_set_timeout(40)` 40 秒超时保护,超时抛 `FunctionTimedOut`。
+
+---
+
+## 二、模式1:普通抠图(阿里云 SDK)
+
+### 2.1 依赖安装
+
+```bash
+pip install alibabacloud-imageseg20191230==3.0.1
+pip install alibabacloud_tea_openapi==0.3.13
+pip install alibabacloud_tea_util==0.3.13
+pip install func_timeout==4.3.5
+pip install pillow==11.1.0
+pip install opencv-python==4.11.0.86
+pip install numpy==1.25.2
+pip install requests==2.32.3
+```
+
+(版本来自 `python/requestments.txt`)
+
+### 2.2 导入包
+
+```python
+from alibabacloud_imageseg20191230.client import Client as imageseg20191230Client
+from alibabacloud_imageseg20191230 import models as imageseg_20191230_models
+from alibabacloud_imageseg20191230.models import SegmentCommodityAdvanceRequest
+from alibabacloud_tea_openapi import models as open_api_models
+from alibabacloud_tea_util.models import RuntimeOptions
+```
+
+### 2.3 客户端初始化(`Segment.create_client`)
+
+```python
+config = open_api_models.Config(
+    access_key_id=AccessKeyId,        # 硬编码在 remove_bg_ali.py L23
+    access_key_secret=AccessKeySecret # 硬编码在 remove_bg_ali.py L24
+)
+config.endpoint = 'imageseg.cn-shanghai.aliyuncs.com'
+client = imageseg20191230Client(config)
+```
+
+### 2.4 调用流程(`RemoveBgALi.get_image_cut`)
+
+1. `Image.open(file_path)` 打开原图,校验 `mode == "RGB"`(PNG 直接返回 None)。
+2. 长边超过 **2000px** 则等比缩放到 2000,记录 `after_need_resize=True`。
+3. 图片存进 `BytesIO`(JPEG 格式),构造请求对象:
+
+   ```python
+   request = imageseg_20191230_models.SegmentCommodityAdvanceRequest()
+   request.image_urlobject = img   # 图片字节流(本地文件流 / BytesIO 均可)
+   runtime = util_models.RuntimeOptions()
+   response = client.segment_commodity_advance(request, runtime)
+   ```
+
+   **请求参数**:`image_urlobject` — 商品图片字节流(能力名称 SegmentCommodity,对应方法 `segment_commodity_advance`)。
+
+4. **返回取值**:
+
+   ```python
+   body = eval(str(response.body))
+   image_url = body["Data"]["ImageURL"]   # 阿里云 OSS 上的抠图结果 PNG 地址
+   ```
+
+5. `requests.get(image_url)` 下载抠图结果,转 PIL 对象。
+6. 若原图被缩放过:把抠图结果放大回原尺寸 → 取 alpha 通道做 mask → OpenCV 腐蚀(`MORPH_ELLIPSE (10,10)` 选区收缩)→ 用 mask 从**原图**重新抠出透明图,与结果拼接(保证原图分辨率与细节)。
+7. `out_file_path` 存在时通过 `ImageSaver.save_image(..., quality=100, dpi=(350,350), _format="PNG")` 保存,返回 PIL 对象。
+
+> 变体 `get_image_cut_new()`:逻辑相同,但不做还原拼接,直接返回抠图结果裁剪后的 `BytesIO`(供 `/api/upload` 上传用)。
+
+---
+
+## 三、模式2:精细化抠图 Pixian(两段式)
+
+实现文件:`python/service/deal_cutout.py`、`python/service/deal_one_image.py`、`python/service/remove_bg_pixian.py`、`python/service/online_request/module_online_data.py`
+
+### 3.1 调用入口(`base_deal.py` L649-661、L736-745)
+
+模式2 不走单图直调,而是先把当前货号文件夹内所有待抠图组装成列表,再批量执行:
+
+```python
+# L649-661:组装 cutImageList
+cutImageList.append({
+    "file_name": file_name,             # 文件名(不含后缀)
+    "file_e": suffix,                   # 后缀,.jpg
+    "file_path": original_image_path,   # 原图完整路径
+    "file": "{}{}".format(file_name, suffix),  # 图片文件名,带后缀
+    "need_cutout": True,                # 必须,需要抠图
+    "out_path": original_move_bg_image_path,  # 抠图结果输出路径
+})
+
+# L736-745:批量执行并轮询等待
+if cutout_mode == "2":
+    dealCutout = DealCutout(windows=None, token=self.token)
+    dealCutout.need_cutout_images = cutImageList
+    dealCutout.run()
+    while True:
+        time.sleep(0.5)
+        if dealCutout.state == 3:   # 1进行中 2停止 3已结束
+            if len(dealCutout.resultData) != len(cutImageList):
+                error_goods_art_no_folder.append(folder_path)  # 有图失败 → 整个文件夹记错误
+            break
+```
+
+**返回取值**:`dealCutout.state == 3` 表示结束;`dealCutout.resultData` 为成功输出的文件路径列表,其数量与 `cutImageList` 不等即视为有失败项。
+
+### 3.2 第一阶段:阿里预抠图预处理(`DealOneImageBeforehand.run`)
+
+对每张图(并发上传槽位最多 4 个,`is_upload_pic_num >= 4` 时排队等待):
+
+1. 打开原图 → EXIF 方向校正 → 转 RGB。
+2. 像素 < 1,000,000:直接用原图,`二次抠图是否缩放=False`。
+3. 否则调用**模式1 的阿里 SDK** `RemoveBgALi.get_image_cut()` 预抠图 → `getbbox()` 定位主体 → 四周**扩边 2.5%** 从原图裁剪。
+4. 裁剪区像素超过 `settings.MAX_PIXIAN_SIZE`(12,000,000):按 `sqrt(max)/sqrt(xy)*0.9` 缩放,`二次抠图是否缩放=True` 并备份原尺寸 PIL 对象。
+5. 结果写入共享字典:
+
+   ```python
+   windows.upload_pic_dict[file_path] = {
+       "url": "", "image_deal_info": image_deal_info,
+       "flag": True,          # 预处理成功标志
+       "_im": cut_image,      # 待二次抠图的 PIL 对象
+   }
+   ```
+
+### 3.3 第二阶段:Pixian 抠图(`DealOneImage.run`)
+
+1. 轮询等待该图的 `upload_pic_dict` 条目就绪(最长 60 秒,超时失败)。
+2. **获取 Pixian 密钥**:
+
+   - 接口:`POST {DOMAIN}/api/ai_image/client/get_key_serect`
+   - 请求头:`{"Authorization": token}`
+   - 返回取值:`response_data["data"]["api_info"]["api_key"]` 与 `["api_serect"]`,组成 HTTP Basic Auth 元组 `(api_key, api_serect)`
+
+3. **调用 Pixian 抠图接口**(`RemoveBgPiXian.run_by_image_im`):
+
+   - 先上传:`upload_image_by_io(im)` → `POST {DOMAIN}/api/upload`(`files={"file": BytesIO}`,无鉴权头,timeout=100),取 `resultData["data"]["url"]`
+   - 域名替换:URL 中 `ossimg.valimart.net` → `img-cuts.valimart.net`
+   - 抠图请求(`Segment.get_no_bg_goods_by_url`):
+
+     | 项 | 值 |
+     |---|---|
+     | 接口 | `POST https://3api.valimart.net/api/v2/remove-background` |
+     | 鉴权 | HTTP Basic Auth `(api_key, api_serect)`(第3步动态获取) |
+     | 请求体 | form 表单:`{"image.url": <上传后的图片URL>}` |
+     | 超时 | 300 秒 |
+     | 返回 | **成功(200)时响应体直接是抠图后的 PNG 图片字节流**(非 JSON),`Image.open(BytesIO(response.content))` 转 PIL |
+
+   - 状态码处理:`200` 成功;`402` Pixian 账号余额不足(重试,第2次失败发钉钉告警 `POST {DOMAIN}/api/ai_image/client/send_message`,body `{"message": text}`);`429` 限流(延迟10秒重试);其他 → 失败。
+
+4. **还原拼接**(与模式4一致):若 `二次抠图是否缩放=True` → `picture_resize_to_original()` 放大回裁剪区尺寸 + mask 腐蚀;新建原图尺寸透明画布,把结果粘贴到"抠图扩边后位置"左上角;`save(out_path, dpi=(350,350))`。
+
+### 3.5 直传变体(`Segment.get_no_bg_goods2`,备用)
+
+```python
+requests.post(
+    'http://47.76.110.118:27777/api/v2/remove-background',
+    files={'image.url': img},          # 图片字节流直传,无需先 /api/upload
+    auth=(k, s),                       # 硬编码密钥,remove_bg_pixian.py L11-12
+)
+# 200 → response.content 为抠图 PNG 字节流
+```
+
+> 依赖:仅 `requests` + `pillow`,Pixian 为标准 HTTP 接口,无需额外 SDK。密钥硬编码于 `remove_bg_pixian.py`(k/s)与平台下发(get_key_serect),建议移到配置管理。
+
+---
+
+## 四、模式3:旗舰AI抠图(自建平台接口)
+
+### 4.1 调用流程(`RemoveUltraBackground.ultra_segment_fast`)
+
+1. 打开原图,EXIF 方向校正(`Segment.get_image_orientation`),校验 RGB。
+2. 长边超过 **2000px** 等比缩放。
+3. **先上传**:调用 `uploadImage(im)`(见第五节 `/api/upload`)拿到图片 URL。
+4. **调用抠图接口**(`Segment.get_bo_bg_goods_ultra_background`):
+
+   - 接口地址:`{settings.DOMAIN}/api/ai_image/segment_shadow/segment_service`
+     (`DOMAIN` 定义在 `settings.py` L252:非 dev 环境 `https://dev2.valimart.net`,dev 环境 `https://dev2.pubdata.cn`)
+   - 请求方式:`POST`,`Content-Type: application/json`
+   - 请求头:`{"Authorization": "Bearer " + settings.USER_TOKEN}`
+   - 请求体(JSON):
+
+     | 字段 | 类型 | 说明 |
+     |---|---|---|
+     | `image_url` | str | `/api/upload` 返回的图片 URL |
+     | `is_shadow` | bool | 是否生成阴影,默认 `False` |
+
+   - 调用代码:
+
+     ```python
+     response = requests.post(api_url, json=data, headers=post_headers)
+     ```
+
+5. **返回取值**:
+
+   ```python
+   resultData = response.json()
+   data = resultData.get("data")            # 为空 → 返回 None(失败)
+   cutout_image = data.get("cutout_image")  # 抠图结果图片 URL;为空 → 返回 None
+   ```
+
+6. `requests.get(cutout_image)` 下载结果转 PIL;若缩放过则执行与模式1相同的"放大回原尺寸 + mask 腐蚀 + 原图重抠"还原处理。
+7. 保存 PNG(quality=100, dpi=350)并返回 PIL 对象。
+
+---
+
+## 五、模式4:旗舰AI抠图-精细(两段式)
+
+`RemoveUltraBackground.run_ultra_segment(file_path, out_file_path)`,流程:
+
+1. **阿里预抠图**(`get_image_cut_ali`):
+   - 原图像素 < 1,000,000:直接用原图,不预处理。
+   - 否则调用模式1的 `RemoveBgALi.get_image_cut()` 先粗抠一次,用 `getbbox()` 找到主体位置,四周**扩边 2.5%** 裁剪出主体区域;
+   - 裁剪区域像素超过 `settings.MAX_PIXIAN_SIZE`(12,000,000)时二次缩放(缩放系数 `sqrt(max)/sqrt(xy)*0.9`),记录 `二次抠图是否缩放=True` 并备份原尺寸 PIL 对象。
+2. **旗舰接口二次抠图**(`get_image_cut_ultra`):把裁剪后的图走第三节同样的 `/api/ai_image/segment_shadow/segment_service` 接口,取 `data.cutout_image` 下载。
+3. **还原拼接**:
+   - 若二次缩放过 → `picture_resize_to_original()` 把抠图结果放大回裁剪区尺寸并做 mask 腐蚀还原;
+   - 新建与原图同尺寸的透明画布 `Image.new("RGBA", 原始图片大小, (0,0,0,0))`,把抠图结果粘贴回"抠图扩边后位置"的左上角坐标;
+   - `save(out_file_path, dpi=(350,350))` 保存,返回 PIL 对象;任何异常记录日志并返回 None。
+
+---
+
+## 六、模式5:旗舰AI抠图-透明物品(玻璃制品)
+
+`RemoveBgGlasses.glasses_segment(file_path, out_file_path)`,流程与模式3几乎一致,仅接口不同:
+
+- 接口地址:`{settings.DOMAIN}/api/ai_image/segment_shadow/transparent_segment`
+- 缩放阈值:长边 **4000px**(旗舰版为 2000px)
+- 请求体:`{"image_url": <上传URL>, "is_shadow": False}`
+- 请求头:`{"Authorization": "Bearer " + settings.USER_TOKEN}`
+- 返回取值:`resultData["data"]["cutout_image"]`(图片 URL)
+- 后续同样执行 EXIF 校正、下载、mask 腐蚀还原、保存 PNG。
+
+---
+
+## 七、登录鉴权:获取 token 流程
+
+所有平台接口(`/api/upload`、旗舰抠图、余额查询等)都依赖登录 token。token 获取分三步:登录 → (按需)选择企业 → 使用 token。
+
+### 7.1 第一步:账号登录
+
+- 接口:`POST {settings.DOMAIN}/api/auth/login`
+- 请求头:`Content-Type: application/json;charset=UTF-8`
+- 请求体(JSON):
+
+  | 字段 | 示例 | 说明 |
+  |---|---|---|
+  | `site` | `1` | 站点标识 |
+  | `username` | `"15001208995"` | 手机号账号 |
+  | `password` | `"753159"` | 密码 |
+  | `invite_code` | `""` | 邀请码,可空 |
+  | `type` | `1` | 登录类型 |
+  | `device` | `"aigc-pc"` | 设备标识 |
+
+- 调用示例:
+
+  ```bash
+  curl --url "https://dev2.pubdata.cn/api/auth/login" \
+    -H "content-type: application/json;charset=UTF-8" \
+    --data-raw '{"site":1,"username":"15001208995","password":"753159","invite_code":"","type":1,"device":"aigc-pc"}'
+  ```
+
+- 响应示例:
+
+  ```json
+  {
+    "data": {
+      "token": "ebc9d472ec392dac1bea3bbd4ef20577316196ae",
+      "is_set_password": true,
+      "is_need_select_company": 40006,
+      "is_real_name": 1
+    },
+    "code": 0,
+    "message": "success"
+  }
+  ```
+
+- **返回取值**:`data.token` — 后续所有接口的鉴权凭证(`Authorization: Bearer {token}`)。
+- **关键判断**:`data.is_need_select_company`:
+  - `== 40006`(未选择组织)→ 必须先执行 7.2 选择企业,token 才能正常使用;
+  - 其他值 → 跳过 7.2,直接使用 token。
+
+### 7.2 第二步(仅当 is_need_select_company == 40006):获取企业列表并选择
+
+**① 获取当前账号下的企业列表**
+
+- 接口:`GET {settings.DOMAIN}/api/backend/account/get_account_company?site=1`
+- 请求头:`Authorization: Bearer {token}`(7.1 返回的 token)
+
+  ```bash
+  curl --url "https://dev2.pubdata.cn/api/backend/account/get_account_company?site=1" \
+    -H "authorization: Bearer 9f1ac7af7ce1b42bd8c5c78e4f059b2ddedbdbd2"
+  ```
+
+- 返回取值:企业列表,从中取用户要进入的**企业 id**。
+
+**② 选择企业登录**
+
+- 接口:`POST {settings.DOMAIN}/api/auth/select_company`
+- 请求头:`Authorization: Bearer {token}`
+- 请求体:`multipart/form-data`,表单字段 `id` = 企业 id
+
+  ```bash
+  curl --location "https://dev2.pubdata.cn/api/auth/select_company" \
+    -H "Authorization: Bearer {token}" \
+    --form 'id="企业id"'
+  ```
+
+- 选择成功后,**继续使用 7.1 的 token** 调用其他业务接口。
+
+### 7.3 错误码对照表(`App\Helper\ErrorCode`)
+
+| 错误码 | 常量 | 含义 |
+|---|---|---|
+| 40000 | `COMMON` | 一般错误 |
+| 40001 | `NO_AUTH` | 总后台未登录 |
+| 40002 | `USER_DISABLE` | 账号被禁用 |
+| 40003 | `NO_PERMISSION` | 用户无权限访问 |
+| 40006 | `NO_SELECT_COMPANY` | **未选择组织**(需走 7.2 选企业) |
+| 40007 | `NO_COMPANY` | 用户未在任何组织中 |
+| 40008 | `CURRENT_COMPANY_STOP` | 当前企业已停用 |
+| 40401 | `AI_NO_AUTH` | AI 无授权权限 |
+| 400011 | `BODY_NOT_MATCH` | 请求体不匹配 |
+| 500401 | `SHOP_DISABLE` | 门店已禁用 |
+
+### 7.4 token 在项目中的注入位置
+
+获取到的 token 赋给 `settings.USER_TOKEN`,供全部抠图/上传接口使用:
+
+- `api.py` L1650 / L1692:`settings.USER_TOKEN = hlm_token`
+- `api.py` L1917:`settings.USER_TOKEN = params.token`(接口入参携带)
+- `sockets/message_handler.py` L690:`settings.USER_TOKEN = token`(WebSocket 登录消息)
+
+---
+
+## 八、`/api/upload` 图片上传接口
+
+### 8.1 接口定义
+
+| 项 | 值 |
+|---|---|
+| 完整地址 | `{settings.DOMAIN}/api/upload`(域名同上,非 dev:`https://dev2.valimart.net`) |
+| 请求方式 | `POST` |
+| Content-Type | `multipart/form-data`(由 requests 的 `files` 参数自动生成) |
+| 表单字段 | `file` — 图片文件 / 字节流 |
+| 鉴权 | 请求头 `Authorization`(两种写法见下) |
+
+### 8.2 三种调用方式(项目中实际存在)
+
+**方式A:PIL 对象 → 字节流上传**(`image_deal_base_func.py` L8-27、`remove_bg_ali.py` L27-46、`api.py` L2446-2465)
+
+```python
+def uploadImage(im: Image) -> 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"]
+```
+
+**方式B:本地文件路径上传**(`online_request/module_online_data.py` L54-61)
+
+```python
+post_headers = {"Authorization": self.token}   # token 形如 "Bearer xxx"
+resultData = requests.post(
+    settings.DOMAIN + "/api/upload",
+    files={"file": open(local_path, "rb")},
+    headers=post_headers
+).json()
+return resultData["data"]["url"]
+```
+
+**方式C:BytesIO 直接上传(无文件名三元组)**(`remove_bg_pixian.py` L175-193、`api.py` L1776-1783)
+
+```python
+resultData = requests.post(url, files={"file": img}, timeout=100).json()
+return resultData["data"]["url"]
+```
+
+### 8.3 鉴权说明
+
+- 项目内两种写法并存:
+  - `{"Authorization": settings.USER_TOKEN}` — USER_TOKEN 由外部注入(`api.py` L1650/L1917、`sockets/message_handler.py` L690),通常已含 `Bearer ` 前缀;
+  - `{"Authorization": "Bearer " + token}` — 显式拼接。
+- 若 `settings.USER_TOKEN` 为空,抠图接口调用直接返回 None。
+
+### 8.4 返回结构与取值
+
+响应为 JSON:
+
+```json
+{
+  "code": 0,
+  "msg": "...",
+  "data": {
+    "url": "https://ossimg.valimart.net/xxxx.jpg"
+  }
+}
+```
+
+**取值方式**:`resultData["data"]["url"]` — 上传成功后的 OSS 图片外链,直接作为后续抠图接口的 `image_url` 入参。
+
+> 注:`shoe_main_image_compose.py` L192-215 的 `upload_image_by_io` 是该接口的合成场景封装(RGBA 存 PNG / 其余存 JPEG,带 100s 超时,失败返回 None);`api.py` L2482 展示了 URL 后拼接 OSS 处理参数的用法:`uploadImageIM(pil) + "?x-oss-process=image/format,jpg"`。
+
+---
+
+## 九、相关辅助接口(同域名,供参考)
+
+`Segment` 类中还有两个基于 `/api/upload` 的辅助接口(鞋类合成阴影场景使用):
+
+| 接口 | 方法 | 请求体 | 返回取值 |
+|---|---|---|---|
+| `{DOMAIN}/api/ai_image/segment_shadow/...`(阴影生成) | `get_platform_shadow` | `{"oraginal_image": <上传URL>}` | `resultData["data"]["oss_url"]` |
+| 阴影合成 | `get_ultra_shadow` | `{"oraginal_image": <URL>, "cutout_image": <URL>, "opacity": <float>, "bright_target": <float>}` | `resultData["data"]["matte_image"]` |
+
+均为 `POST` + JSON + `Authorization: Bearer {USER_TOKEN}`,`data` 或目标字段为空即视为失败返回 None。
+
+---
+
+## 十、关键方法:`picture_resize_to_original` 尺寸还原
+
+定义位置:`remove_bg_ali.py` L693-733(`RemoveUltraBackground`)、`deal_one_image.py` L306-346(`DealOneImage`),两份实现相同。模式2、模式4 在"二次抠图是否缩放=True"时调用。
+
+### 10.1 作用
+
+第三方抠图是在**缩小后的图**上执行的,返回结果分辨率低。本方法把低分辨率抠图结果**放大回原尺寸**,用其 alpha 通道生成选区 mask,再从**原尺寸原图**中重新抠出主体,保证输出保留原图细节。
+
+### 10.2 具体实现
+
+```python
+def picture_resize_to_original(self, _img, original_im):
+    """
+    _img        需要还原的抠图结果(小尺寸 RGBA PIL 对象)
+    original_im 原尺寸的原图 PIL 对象(抠图扩边后PIL对象)
+    """
+    # 1. 将抠图结果放大到原始图大小(resample=1 即 BICUBIC)
+    _img = _img.resize(original_im.size, resample=1)
+
+    # 2. 用抠图结果的 alpha 通道生成黑白 mask
+    #    黑底图 new_big_mask,把纯白 white 按 alpha 通道粘贴上去
+    #    → 主体处为白色(255),背景处为黑色(0)
+    new_big_mask = Image.new("RGB", _img.size, (0, 0, 0))
+    white = Image.new("RGB", _img.size, (255, 255, 255))
+    new_big_mask.paste(white, mask=_img.split()[3])   # split()[3] = alpha 通道
+
+    # 3. 转 OpenCV 灰度图并二值化:非 255 的像素(半透明边缘)全部归 0
+    mask = cv2.cvtColor(np.asarray(new_big_mask), cv2.COLOR_BGR2GRAY)
+    mask[mask != 255] = 0
+
+    # 4. 形态学腐蚀:椭圆核 (10,10),选区整体向内收缩约 10px
+    #    目的:去掉放大后边缘的白边/毛边
+    kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (10, 10))
+    erode_im = cv2.morphologyEx(mask, cv2.MORPH_ERODE, kernel)
+
+    # 5. 转回 PIL,用收缩后的 mask 从"原尺寸原图"重新抠出透明图
+    mask = Image.fromarray(cv2.cvtColor(erode_im, cv2.COLOR_GRAY2RGBA))
+    transparent_im = Image.new("RGBA", original_im.size, (0, 0, 0, 0))
+    transparent_im.paste(original_im, (0, 0), mask.convert("L"))  # L=灰度作透明度
+
+    # 6. 将原图细节抠出的透明图叠加回放大后的抠图结果上并返回
+    _img.paste(transparent_im, (0, 0), transparent_im)
+    return _img
+```
+
+### 10.3 流程图解
+
+```
+小尺寸抠图结果(_img)          原尺寸原图(original_im)
+      │                              │
+ 放大到原尺寸                         │
+      │                              │
+ 取 alpha 通道 → 黑白 mask            │
+      │                              │
+ 二值化(去半透明边) → 腐蚀收缩10px     │
+      │                              │
+      └──── mask 作为选区 ────→ 从原图抠出 transparent_im
+                                     │
+              叠加合并 ←─────────────┘
+                     │
+              返回 RGBA 抠图结果(原图分辨率、原图细节)
+```
+
+> 模式1/3/5 中的"放大回原尺寸 + mask 腐蚀 + 原图重抠"内联代码与本方法逻辑完全一致,只是未抽成函数。
+
+---
+
+## 十一、公共约定汇总
+
+1. **输入限制**:原图必须为 RGB 模式(JPG),PNG 模式直接返回 None。
+2. **EXIF 方向校正**:旗舰/透明模式调用前按 EXIF Orientation 标签(0x0112)旋转/翻转。
+3. **缩放阈值**:普通/旗舰 2000px,透明物品 4000px,精细模式二次抠图上限 `MAX_PIXIAN_SIZE=12,000,000` 像素。
+4. **还原策略**:缩放过的小图结果,放大回原尺寸后用 alpha 通道做 mask,OpenCV 椭圆核 `(10,10)` 腐蚀收缩选区,再从原图重抠拼接,保证输出为原图分辨率。
+5. **输出**:统一保存为 PNG(quality=100、dpi=350),返回 RGBA PIL 对象。
+6. **超时**:单图 40 秒(`func_timeout`),超时/异常在 `base_deal.py` 捕获并通过钉钉 `sendDingMsg` 告警、记入失败列表。

+ 1 - 1
python/api.py

@@ -1214,7 +1214,7 @@ def get_photo_records(page: int = 1, size: int = 5, goods_art_no: str = None):
         # 按货号分组
         items_by_goods = {}
         for item in all_items:
-            print("all_items", all_items)
+            # print("all_items", all_items)
             goods_art_no = item.PhotoRecord.goods_art_no
             if goods_art_no not in items_by_goods:
                 items_by_goods[goods_art_no] = []

+ 132 - 0
python/command.txt

@@ -0,0 +1,132 @@
+# 循环运动耐久测试
+{'code': 2, 'is_auto_enable': 0,"max_speed":1800,"min_pos":50,"max_pos":1500}
+# 测试传感器
+{'code': 6}
+# 自动回落测试
+{'code': 7,'max_speed':200,'servo_back_torque':16}
+# 刹车反向处理
+{"code": 11, "is_normal": false, "servo_enable": false}
+# 刹车正向处理
+{"code": 11, "is_normal": true, "servo_enable": true}
+#清空错误
+{"code": 0, "is_clearn": true, "clearn_all": true}
+#循环测试传感器
+{"code": 12, "is_out_time": 360}
+#查询错误
+{"code": 14}
+#读取伺服配置
+{"code": 3,"is_get":1,"menu":3188,"value":0,"is_save":False,}
+#设置伺服配置
+{"code": 3,"is_get":0,"menu":3188,"value":0"is_save":False,}
+
+#转盘init 忽略限位
+{"code": 13,"device_list":"turntable_steering"}
+#前后init 忽略限位
+{"code": 13,"device_list":"turntable_position_motor"}
+#焦段init 忽略限位
+{"code": 13,"device_list":"camera_zoom_motor"}
+
+#读取指定ID配置
+{"code": 15,"addr":1,"method":"get","value":0,}
+#设置指定ID配置
+{"code": 15,"addr":1,"method":"save","value":0,}
+#设置密钥
+{"code": 17,"key":"xxxxxxxx"}
+#三代机电机测试
+{"code": 18,"torque":2,"target_value":10}
+#前后减速机是否正常
+{"code": 19,"up_electric":500,"max_electric":2000,"max_speed":500,"dir_change":false,"time_out":2,}
+# 获取设备ID
+55 55 2 5a 1 a4
+
+# 强制初始化
+55 55 03 02 63 01 99
+
+
+# 初始化(不强制)
+55 55 03 02 63 00 9a
+
+# 高度0
+55 55 0e 01 01 01 00 00 05 78 01 90 00 64 00 01 00 89
+# 高度50
+55 55 0e 01 01 01 00 32 05 78 01 90 00 64 00 01 00 57
+# 高度999
+55 55 12 01 01 01 03 e7 2e e0 03 20 02 bc 00 01 00 01 00 5e 01 c2
+# 高度1500
+55 55 12 01 01 01 05 dc 2e e0 03 20 02 bc 00 01 00 01 00 5f 01 ca
+
+# 相机角度0
+55 55 0e 01 00 01 00 00 17 70 01 f4 01 f4 00 01 00 8b
+# 相机角度10
+55 55 0e 01 00 01 00 64 17 70 01 f4 01 f4 00 01 00 27
+
+# 转盘舵机 0
+55 55 0e 01 02 01 00 00 0b b8 00 c8 00 c8 00 01 00 a7
+# 转盘舵机 10
+55 55 0e 01 02 01 00 64 0b b8 00 c8 00 c8 00 01 00 43
+
+# 前后移动0
+55 55 0e 01 07 01 00 00 05 78 01 90 00 64 00 01 00 83
+# 前后移动20
+55 55 0e 01 07 01 00 14 05 78 01 90 00 64 00 01 00 6f
+
+# 蜂鸣器
+55 55 06 03 05 01 00 01 00 f5
+
+# 翻板运动
+55 55 06 03 03 01 00 01 00 f7
+# 翻板120度
+55 55 13 01 03 01 04 b0 00 02 00 01 00 01 01 00 00 01 00 2c 00 00 14
+# 翻板130度
+55 55 13 01 03 01 05 14 00 02 00 01 00 01 01 00 00 01 00 2d 00 00 ae
+# 翻板150度
+55 55 13 01 03 01 05 dc 00 02 00 01 00 01 01 00 00 01 00 2f 00 00 e4
+
+# 激光控制 开
+55 55 06 03 04 01 00 01 00 f6
+# 激光控制 关
+55 55 06 03 04 00 00 01 00 f7
+
+
+# 测试:camera_steering
+55 55 03 98 00 00 67
+# 测试:camera_high_motor
+55 55 03 98 01 00 66
+# 测试:turntable_steering
+55 55 03 98 02 00 65
+# 测试:overturn_steering
+55 55 03 98 03 00 64
+# 测试:laser_position
+55 55 03 98 04 00 63
+# 测试:buzzer
+55 55 03 98 05 00 62
+# 测试:split
+55 55 03 98 06 00 61
+# 测试:turntable_position_motor
+55 55 03 98 07 00 60
+# 测试:mp3_player
+55 55 03 98 08 00 5f
+# 测试:overturn_steering_only
+55 55 03 98 09 00 5e
+# 测试:camera_zoom_motor
+55 55 03 98 0a 00 5d
+# 测试:mcu
+55 55 03 98 63 00 04
+
+
+# 自动往下运行
+55 55 69 96 0 3 0 c8 b 0 0 0 0 0 3 1 1 0 0 0 0 0 0 78 1 1 0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 1 1 0 2
+
+# 设备关机
+55 55 1 35 ca
+
+
+
+
+
+
+
+
+
+
+

+ 60 - 1
python/mcu/DeviceControl.py

@@ -66,6 +66,8 @@ class DeviceControl(BaseClass, metaclass=SingletonType):
             "get_motor_value": 119,  # 获取当前电机值;逻辑值
             "set_zero_debug": 120,  # 简单的设备归零,用于调试
             "set_stepper_disable": 121,  # 释放,或恢复使能
+            "test_devices": 152,  # 测试设备是否正常
+            "deal_by_json": 154,  # 使用json进行数据下发
         }
         self.serial_ins = None
         self.mcu_deviation_set = McuDeviationSet(self)
@@ -220,6 +222,57 @@ class DeviceControl(BaseClass, metaclass=SingletonType):
                    1 if flag else 0,
                    ]
             self.add_send_data_queue(buf)
+        # 使用json进行数据下发
+
+    def send_json_to(self, data):
+        try:
+            data = data.encode()
+            send_data = [self.command["deal_by_json"]]
+            send_data.extend(data)
+            # self.serial_ins.write_cmd(send_data)
+        except BaseException as e:
+            print("212", e)
+            return False
+        print("280----buf:", self.change_hex_to_int(send_data))
+        # =============================
+        buf = bytearray(b'')
+        buf.extend([0x55, 0x55, (0xff & len(send_data))])
+        buf.extend(send_data)
+        buf.extend([0xff & ~sum(send_data)])
+        print("增加校验码后:", self.change_hex_to_int(buf))
+        self.add_send_data_queue(send_data)
+        return True
+
+    # 发送命令到MCU
+    def send_hex_cmd_to(self, data: str, need_del_cr=True):
+        while 1:
+            if "  " in data:
+                data = data.replace("  ", " ")
+            else:
+                break
+        data = data.strip()
+        data = [int(x, 16) for x in data.split()]
+
+        if need_del_cr:
+            send_data = []
+            if len(data) > 3:
+                data = data[3:-1]
+            # send_data.extend([0x55, 0x55, (0xff & len(data))])
+            send_data.extend(data)
+            # send_data.extend([0xff & ~sum(data)])
+        else:
+            send_data = []
+            send_data.extend(data)
+
+        print("303----buf:", self.change_hex_to_int(send_data))
+        # =============================
+        buf = bytearray(b'')
+        buf.extend([0x55, 0x55, (0xff & len(send_data))])
+        buf.extend(send_data)
+        buf.extend([0xff & ~sum(send_data)])
+        print("增加校验码后:", self.change_hex_to_int(buf))
+
+        self.add_send_data_queue(send_data)
 
     def one_key_down(self):
         cmd = [0x55, 0x55, 0x69, 0x96, 0x00, 0x03, 0x00, 0xC8, 0x0B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x01, 0x01,
@@ -628,8 +681,14 @@ class DeviceControl(BaseClass, metaclass=SingletonType):
     def send_get_all_info_to_mcu(self):
         if self.is_get_mcu_state is False:
             return
+        if self.serial_ins is None:
+            logger.info("串口未连接,跳过发送获取全部信息命令")
+            return
         data = [self.command["get_all_info"], 1]
-        self.serial_ins.write_cmd(data)
+        try:
+            self.serial_ins.write_cmd(data)
+        except Exception as e:
+            logger.info(f"发送获取全部信息命令失败:{e}")
 
     def print_mcu_error_data(self, receive_data):
         # 扫码数据

+ 13 - 4
python/mcu/capture/smart_shooter_class.py

@@ -174,7 +174,7 @@ class SmartShooter(metaclass=SingletonType):
                     await self.sendMessageSocket(message)
                 return False, msg_send
             cameraInfo = json_msg.get("CameraInfo")
-            print("相机状态msg_result", cameraInfo)
+            # print("相机状态msg_result", cameraInfo)
             if cameraInfo == None or len(cameraInfo) == 0:
                 self.connect_status = False
                 msg_send = (
@@ -339,7 +339,15 @@ class SmartShooter(metaclass=SingletonType):
         :param properties: 参数字典,如 {"ISO": "200", "Aperture": "8", "ShutterSpeed": "1/60"}
         :return: (是否全部成功, {参数名: 是否成功})
         """
-        if not properties:
+        print("批量设置拍照参数", properties, CameraKey)
+        # properties 可能传入 JSON 字符串,统一归一化为 dict
+        if isinstance(properties, str):
+            try:
+                properties = json.loads(properties)
+            except (json.JSONDecodeError, ValueError) as e:
+                print("批量设置拍照参数 properties 解析失败", e)
+                return False, {}
+        if not properties or not isinstance(properties, dict):
             return False, {}
         camera_states, _ = await self.GetCameraInfo(is_send=False, CameraKey=CameraKey)
         if not camera_states:
@@ -603,7 +611,7 @@ class SmartShooter(metaclass=SingletonType):
         执行拍照
         """
         camera_states, _ = await self.GetCameraInfo(is_send=True, CameraKey=CameraKey)
-        print("camera_states CameraShooter", camera_states)
+        print("camera_states CameraShooter", camera_states, camera_configs)
         if not camera_states:
             return False, "请先连接相机"
         socket, context = self.__create_req()
@@ -619,7 +627,8 @@ class SmartShooter(metaclass=SingletonType):
                 else:
                     print("low_iso 等于auto就不设置")
             else:
-                await self.setCameraProperties(camera_configs, CameraKey=CameraKey)
+                all_ok, property_result = await self.setCameraProperties(camera_configs, CameraKey=CameraKey)
+                print("批量参数设置结果:", all_ok, property_result)
             req = {}
             req["msg_type"] = "Request"
             req["msg_id"] = "Shoot"

+ 125 - 0
python/mcu/one_data_set_control.py

@@ -0,0 +1,125 @@
+from conifg_info import return_false_on_error
+# from mcu.McuDebug import McuDebug
+from utils.SingletonType import SingletonType
+# from module.base_mode.dynamic_parameter import *
+import json
+import ast
+import re
+import os
+
+
+class OneDataSet(metaclass=SingletonType):
+
+    def __init__(self):
+        super().__init__()
+        self.mcu = None
+        # self.mcu_debug = McuDebug(windows, is_debug=False, is_deviation=True)
+        # self.init()
+
+    # def init(self):
+    #     self.create_command_list()
+
+    # 发送json给到mcu
+    @return_false_on_error
+    def deal_send_json_to_mcu(self, command):
+        _text = command.strip()
+        if _text.startswith("55"):
+            flag = "cmd命令"
+        elif _text.startswith("{"):
+            flag = "json"
+        else:
+            flag = "cmd命令(无校验)"
+
+        if flag == "json":
+            try:
+                print("json_text:{}".format(_text))
+                # json_text 可能是文本格式的 JSON(如 {"code":1}),
+                # 先解析成对象再重新序列化,规范化后再下发
+                json_text = self.safe_load_json(_text)
+                json_text = json.dumps(json_text)
+            except Exception as e:
+                print("错误:", str(e))
+                return False
+            print("json_text:{}".format(json_text))
+            self.mcu.send_json_to(data=json_text)
+
+        if flag == "cmd命令":
+            self.mcu.send_hex_cmd_to(data=_text, need_del_cr=True)
+
+        if flag == "cmd命令(无校验)":
+            # 需要增加55 55 校验位
+            self.mcu.send_hex_cmd_to(data=_text, need_del_cr=False)
+        return True
+
+    def safe_load_json(self, text: str):
+        """
+        兼容各种非标准JSON字符串的解析函数
+        优先级: 标准JSON → 单引号替换 → Python字面量 → 宽松JSON
+        """
+        if not isinstance(text, str) or not text.strip():
+            raise ValueError("输入必须是非空字符串")
+
+        text = text.strip()
+
+        # 1. 尝试标准JSON解析
+        try:
+            return json.loads(text)
+        except json.JSONDecodeError:
+            pass
+
+        # 2. 单引号 → 双引号(仅当没有双引号冲突时)
+        if "'" in text and '"' not in text:
+            try:
+                fixed = text.replace("'", '"')
+                return json.loads(fixed)
+            except json.JSONDecodeError:
+                pass
+
+        # 3. 尝试Python字面量解析(处理True/False/None/单引号等)
+        try:
+            result = ast.literal_eval(text)
+            if isinstance(result, (dict, list)):
+                return result
+        except (ValueError, SyntaxError):
+            pass
+
+        # 4. 宽松JSON:处理尾部逗号、未转义单引号等
+        try:
+            # 移除尾部逗号
+            cleaned = re.sub(r',\s*([}\]])', r'\1', text)
+            # 将未转义的单引号替换为双引号(简单场景)
+            cleaned = re.sub(r"(?<!\\)'", '"', cleaned)
+            return json.loads(cleaned)
+        except json.JSONDecodeError:
+            pass
+        raise ValueError(f"无法解析的JSON字符串: {text[:100]}")
+
+    # 读取本地常用命令-JSON.txt,解析出名称和JSON内容
+    def read_command_file(self):
+        path = os.path.join(os.getcwd(), "command.txt")
+        commands = []
+        if not os.path.exists(path):
+            return commands
+        with open(path, "r", encoding="utf-8") as f:
+            lines = f.readlines()
+        name = None
+        for line in lines:
+            line = line.strip()
+            if not line:
+                continue
+            if line.startswith("#"):
+                name = line.lstrip("#").strip()
+                continue
+            if name is not None:
+                commands.append((name, line))
+                name = None
+        return commands
+
+    # 左侧渲染常用命令按钮列表
+    def create_command_list(self):
+        commands = self.read_command_file()
+        json_dicts = []
+        for name, json_text in commands:
+            # print(name, json_text)
+            json_dicts.append({"name": name, "command": json_text})
+        return json.dumps(json_dicts)

+ 27 - 0
python/sockets/message_handler.py

@@ -18,6 +18,7 @@ from concurrent.futures import ThreadPoolExecutor
 from functools import partial
 from logger import logger
 import stat
+from mcu.one_data_set_control import OneDataSet
 
 # 创建全局线程池
 executor = ThreadPoolExecutor(max_workers=4)
@@ -910,6 +911,32 @@ async def handlerSend(
             )
             await manager.send_personal_message(data, websocket)
             return
+        case "get_one_data_set":
+            onseData = OneDataSet()
+            result = onseData.create_command_list()
+            data = manager.jsonMessage(
+                code=0,
+                msg=f"获取测试命令",
+                msg_type='get_one_data_set',
+                data={"commands": json.loads(result)}
+            )
+            await manager.send_personal_message(data, websocket)
+        case "send_get_one_data_set":
+            command = data.get("command")
+            onseData = OneDataSet()
+            device_ctrl = DeviceControl(
+                websocket_manager=manager, smart_shooter=smart_shooter
+            )
+            if not device_ctrl.is_running:
+                data = manager.jsonMessage(
+                    code=-1,
+                    msg=f"设备未连接",
+                    msg_type="send_get_one_data_set",
+                )
+                await manager.send_personal_message(data, websocket)
+                return
+            onseData.mcu = device_ctrl
+            onseData.deal_send_json_to_mcu(command)
         case _:
             data = manager.jsonMessage(code=1, msg="未知消息")
             await manager.send_personal_message(data, websocket)

+ 1 - 1
python/sockets/socket_server.py

@@ -284,7 +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.close_anheng_scale()
     device_ctrl.mcu_exit = True
     device_ctrl.p_list = []
     device_ctrl.temp_ports_dict = {}

+ 41 - 37
python/temp.py

@@ -1,41 +1,45 @@
 from mcu.anheng_scale import AnhengScale
 import time
+from mcu.one_data_set_control import OneDataSet
 
 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()
+    onseData = OneDataSet(None)
+    result = onseData.create_command_list()
+    print('result', result)
+    # 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()