Преглед на файлове

feat(api): 添加商品测量和标定功能

- 新增check_goods_art_no_cutout_dir函数检查货号抠图目录
- 添加generate_calib接口实现标定功能
- 添加measurer_object接口实现商品测量功能
- 新增Calibrator和ShoeMeasurer类封装标定和测量逻辑
- 添加natsorted导入支持自然排序
- 新增CalibRequest和MeasurerRequest模型定义
- 实现基于抠图的多视角测量算法
- 添加图像处理和标定工具函数库
rambo преди 5 часа
родител
ревизия
d78ee1ae3f
променени са 5 файла, в които са добавени 782 реда и са изтрити 3 реда
  1. 70 1
      python/api.py
  2. 24 2
      python/models.py
  3. 0 0
      python/service/measure/__init__.py
  4. 506 0
      python/service/measure/objmark_api.py
  5. 182 0
      python/service/measure/turntable_calib_utils.py

+ 70 - 1
python/api.py

@@ -1,5 +1,5 @@
 from re import search, match
-from natsort.natsort import order_by_index
+from natsort.natsort import order_by_index, natsorted
 from sqlalchemy import func
 from models import *
 import requests
@@ -2211,3 +2211,72 @@ async def import_images_from_dir(params: ImportDirs):
     except Exception as e:
         logger.error(f"API 调用异常: {str(e)}")
         raise UnicornException(f"{str(e)}")
+
+
+def check_goods_art_no_cutout_dir(goods_art_no):
+    """
+    遍历settings.OUTPUT_DIR子目录,检查货号目录及已抠图目录是否存在且有文件
+    :param goods_art_no: 货号
+    :return: 存在则返回货号目录地址,不存在或无文件返回None
+    """
+    if not goods_art_no or not os.path.exists(settings.OUTPUT_DIR):
+        return None
+    try:
+        for date_dir in os.listdir(settings.OUTPUT_DIR):
+            date_path = os.path.join(settings.OUTPUT_DIR, date_dir)
+            if not os.path.isdir(date_path):
+                continue
+            goods_art_no_path = os.path.join(date_path, goods_art_no)
+            if not os.path.isdir(goods_art_no_path):
+                continue
+            cutout_path = os.path.join(goods_art_no_path, "原始图_已抠图")
+            if not os.path.isdir(cutout_path):
+                continue
+            if os.listdir(cutout_path):
+                return goods_art_no_path
+    except Exception as e:
+        logger.error(f"检查货号已抠图目录异常: {str(e)}")
+    return None
+
+
+@app.post("/generate_calib")
+async def import_images_from_dir(params: CalibRequest):
+    goods_art_no = params.goods_art_no
+    goods_path = check_goods_art_no_cutout_dir(goods_art_no)
+    if not goods_path:
+        raise UnicornException("请先对图像进行抠图")
+    cutout_path = f"{goods_path}/原始图_已抠图/"
+    goods_nobg_fileds = natsorted(os.listdir(cutout_path))
+    print("goods_nobg_fileds", goods_nobg_fileds)
+    '''生成标定物像素宽高'''
+    from service.measure.objmark_api import Calibrator
+    calib = Calibrator(
+        topdown_png=cutout_path + goods_nobg_fileds[2],
+        topdown_known_length_mm=params.topdown_known_length_mm,
+        side_png=cutout_path + goods_nobg_fileds[0],
+        side_known_height_mm=params.side_known_height_mm,
+        front_png=cutout_path + goods_nobg_fileds[1],  # 可选,单独标定宽度方向kw
+        front_known_width_mm=params.front_known_width_mm,
+    ).calibrate()
+    return {"code": 0, "msg": "操作成功", "data": calib}
+
+
+@app.post("/measurer_object")
+async def import_images_from_dir(params: MeasurerRequest):
+    '''生成标定物像素宽高'''
+    from service.measure.objmark_api import ShoeMeasurer
+    goods_art_no = params.goods_art_no
+    goods_path = check_goods_art_no_cutout_dir(goods_art_no)
+    if not goods_path:
+        raise UnicornException("请先对图像进行抠图")
+    cutout_path = f"{goods_path}/原始图_已抠图/"
+    goods_nobg_fileds = natsorted(os.listdir(cutout_path))
+    result = ShoeMeasurer(
+        topdown_png=cutout_path + goods_nobg_fileds[2],
+        side_png=cutout_path + goods_nobg_fileds[0],
+        front_png=cutout_path + goods_nobg_fileds[1],  # 可选,单独标定宽度方向kw
+        calib=params.calib,
+        debug=False,  # 保存 result_*.jpg 标注图
+    ).measure()
+    result["goods_path"] = goods_path
+    return {"code": 0, "msg": "操作成功", "data": result}

+ 24 - 2
python/models.py

@@ -2,6 +2,7 @@ from middleware import *
 import datetime
 from typing import Any
 
+
 class HlmForwardRequest(BaseModel):
     method: str = Field(default="GET", description="请求方法")
     headers: dict = Field(default={}, description="请求头")
@@ -82,7 +83,7 @@ class TemplateItem(BaseModel):
 
     template_id: str = Field(description="模板名称")
     template_local_classes: Any = Field(description="模板名称")
-    template_type : Optional[int] = Field(default=0, description="模板类型;0系统模板;1自定义模板")
+    template_type: Optional[int] = Field(default=0, description="模板类型;0系统模板;1自定义模板")
 
 
 class MaineImageTest(BaseModel):
@@ -153,6 +154,7 @@ class SyncLocalConfigs(BaseModel):
     env: str = Field(default="dev", description="当前环境")
     camera_counts: bool = Field(default=False, description="相机数量")
 
+
 class GenerateImageJson(BaseModel):
     """货号json数据生成"""
 
@@ -182,4 +184,24 @@ class RenameShadow(BaseModel):
 class ImportDirs(BaseModel):
     """重命名阴影文件"""
     dir_path: str = Field(default=None, description="货号数组")
-    goods_art_nos:list[str] = Field(default=["BH73323",'BH94727'], description="货号数组")
+    goods_art_nos: list[str] = Field(default=["BH73323", 'BH94727'], description="货号数组")
+
+
+class CalibRequest(BaseModel):
+    """重命名阴影文件"""
+    goods_art_no: str = Field(default=None, description="货号")
+    side_png: str = Field(default=None, description="侧视图")
+    side_known_height_mm: float = Field(default=None, description="侧视图已知高度")
+    front_png: str = Field(default=None, description="正视图")
+    front_known_width_mm: float = Field(default=None, description="正视图已知宽度")
+    topdown_png: str = Field(default=None, description="俯视图")
+    topdown_known_length_mm: float = Field(default=None, description="俯视图已知长度")
+
+
+class MeasurerRequest(BaseModel):
+    """重命名阴影文件"""
+    goods_art_no: str = Field(default=None, description="货号")
+    # side_png: str = Field(default=None, description="侧视图")
+    # front_png: str = Field(default=None, description="正视图")
+    # topdown_png: str = Field(default=None, description="俯视图")
+    calib: dict = Field(default=None, description="像素标定尺寸")

+ 0 - 0
python/service/measure/__init__.py


+ 506 - 0
python/service/measure/objmark_api.py

@@ -0,0 +1,506 @@
+# -*- coding: utf-8 -*-
+"""
+把 calibrate.py 和 measure_shoe_v2.py 的逻辑拷贝并封装为两个类,
+外部直接 new + 传参使用,不再依赖命令行和 calib.json 文件传递。
+
+    - Calibrator   : 标定。不保存 calib.json,calibrate() 直接返回标定结果 dict
+                     (键名与原来 calib.json 完全一致,可直接喂给 ShoeMeasurer)。
+    - ShoeMeasurer : 测量。debug=True 时保存 result_*.jpg 标注图,
+                     measure() 返回长/宽/高结果 dict。
+
+使用方法:
+    from objmark_api import Calibrator, ShoeMeasurer
+
+    # 1) 标定(结果直接拿到 dict,不落盘)
+    calib = Calibrator(
+        topdown_png="标定尺顶拍抠图.png",
+        topdown_known_length_mm=300,
+        side_png="标定块水平拍抠图.png",
+        side_known_height_mm=100,
+        side_known_length_mm=150,                  # 可选,单独标定长度方向kl
+        front_png="标定块正面拍抠图.png",           # 可选,单独标定宽度方向kw
+        front_known_width_mm=60,
+    ).calibrate()
+
+    # 2) 测量(calib 可以直接传上面拿到的 dict,也可以传 calib.json 路径)
+    result = ShoeMeasurer(
+        topdown_png="鞋顶拍抠图.png",
+        side_png="鞋水平拍抠图.png",
+        front_png="鞋正面拍抠图.png",              # 可选
+        calib=calib,
+        width_slope=0.085833,                      # 可选,顶拍宽度透视修正
+        width_scale0=1.146419,
+        length_factor=1.030640,                    # 可选,长度修正因子
+        debug=True,                                # 保存 result_*.jpg 标注图
+    ).measure()
+
+    print(result["length_mm"], result["width_mm"], result["height_mm"])
+"""
+import datetime
+import os
+
+import cv2
+import numpy as np
+
+from .turntable_calib_utils import (
+    imread_unicode, imread_unicode_unchanged, imwrite_unicode, load_json,
+    mask_from_alpha, composite_on_white, largest_contour
+)
+
+
+# ===========================================================================
+# Calibrator —— 拷贝自 calibrate.py,封装为类,不保存 calib.json
+# ===========================================================================
+
+def _calibrate_topdown_from_mask(png_path, known_length_mm):
+    """顶拍标定:标定物轮廓最长边的像素长度 -> k(mm/像素)"""
+    img = imread_unicode_unchanged(png_path)
+    if img is None:
+        raise FileNotFoundError(f"无法读取图片: {png_path}")
+    mask = mask_from_alpha(img)
+    contour = largest_contour(mask)
+    (cx, cy), (w_px, h_px), angle = cv2.minAreaRect(contour)
+    length_px = max(w_px, h_px)
+    if length_px < 1e-6:
+        raise RuntimeError("顶拍标定物轮廓异常(长度接近0),请检查抠图是否正确。")
+    k = known_length_mm / length_px
+    return k, length_px
+
+
+def _calibrate_side_from_mask(png_path, known_height_mm, known_length_mm=None):
+    """
+    水平拍标定:从同一个标定物同时提取
+      - kh: 竖直方向比例系数(用轮廓最高点到最低点的像素高度)
+      - kl: 水平方向比例系数(用轮廓最左到最右的像素宽度),需提供 known_length_mm
+    分开标定的原因:如果侧拍相机不是严格正对,或存在畸变,
+    水平和竖直方向的比例尺会不一致。
+    """
+    img = imread_unicode_unchanged(png_path)
+    if img is None:
+        raise FileNotFoundError(f"无法读取图片: {png_path}")
+    mask = mask_from_alpha(img)
+    contour = largest_contour(mask)
+
+    ys = contour[:, 0, 1]
+    xs = contour[:, 0, 0]
+    height_px = float(ys.max() - ys.min())
+    length_px = float(xs.max() - xs.min())
+
+    if height_px < 1e-6:
+        raise RuntimeError("水平拍标定物轮廓异常(高度接近0),请检查抠图是否正确。")
+    kh = known_height_mm / height_px
+
+    kl = None
+    if known_length_mm:
+        if length_px < 1e-6:
+            raise RuntimeError("水平拍标定物轮廓异常(水平跨度接近0),请检查抠图是否正确。")
+        kl = known_length_mm / length_px
+
+    return kh, height_px, kl, length_px
+
+
+def _calibrate_front_from_mask(png_path, known_width_mm):
+    """
+    正面拍标定:从标定物正面照片提取水平方向比例系数 kw(mm/像素)。
+    原理与侧拍标定完全一致,只是拍摄角度换成正对物体的一端,测宽度而不是长度。
+    """
+    img = imread_unicode_unchanged(png_path)
+    if img is None:
+        raise FileNotFoundError(f"无法读取图片: {png_path}")
+    mask = mask_from_alpha(img)
+    contour = largest_contour(mask)
+    xs = contour[:, 0, 0]
+    width_px = float(xs.max() - xs.min())
+    if width_px < 1e-6:
+        raise RuntimeError("正面拍标定物轮廓异常(宽度接近0),请检查抠图是否正确。")
+    kw = known_width_mm / width_px
+    return kw, width_px
+
+
+class Calibrator:
+    """
+    标定(类封装版)。new 时传入三张(或两张)标定物抠图PNG及各自的真实尺寸,
+    调用 calibrate() 直接返回标定结果 dict,不保存 calib.json。
+
+    参数(对应原 calibrate.py 的命令行参数):
+        topdown_png              顶拍标定物抠图PNG(带alpha)
+        topdown_known_length_mm  顶拍标定物真实长度(mm),取其最长边作为参照
+        side_png                 水平拍标定物抠图PNG(带alpha)
+        side_known_height_mm     水平拍标定物真实高度(mm),竖直方向那条边
+        side_known_length_mm     可选,水平拍标定物水平方向那条边的真实长度(mm),
+                                 用于单独标定长度方向系数kl
+        front_png                可选,正面拍标定物抠图PNG(带alpha)
+        front_known_width_mm     可选,正面拍标定物真实宽度(mm)
+        verbose                  是否打印标定过程/结果(默认True)
+    """
+
+    def __init__(self, topdown_png, topdown_known_length_mm,
+                 side_png, side_known_height_mm,
+                 side_known_length_mm=None,
+                 front_png=None, front_known_width_mm=None,
+                 verbose=True):
+        self.topdown_png = topdown_png
+        self.topdown_known_length_mm = topdown_known_length_mm
+        self.side_png = side_png
+        self.side_known_height_mm = side_known_height_mm
+        self.side_known_length_mm = side_known_length_mm
+        self.front_png = front_png
+        self.front_known_width_mm = front_known_width_mm
+        self.verbose = verbose
+
+    def calibrate(self):
+        """
+        执行标定,直接返回标定结果 dict(不落盘)。
+        返回键名与旧版 calib.json 完全一致:
+            k_mm_per_px / kh_mm_per_px / [kl_mm_per_px] / [kw_mm_per_px]
+            以及对应的 known_* / pixel_* 记录字段
+        """
+        k, length_px = _calibrate_topdown_from_mask(
+            self.topdown_png, self.topdown_known_length_mm
+        )
+        kh, height_px, kl, side_length_px = _calibrate_side_from_mask(
+            self.side_png, self.side_known_height_mm, self.side_known_length_mm
+        )
+
+        result = {
+            "k_mm_per_px": k,
+            "topdown_known_length_mm": self.topdown_known_length_mm,
+            "topdown_pixel_length": length_px,
+            "kh_mm_per_px": kh,
+            "side_known_height_mm": self.side_known_height_mm,
+            "side_pixel_height": height_px,
+        }
+
+        if self.verbose:
+            print("\n========== 标定结果 ==========")
+            print(f"[顶拍/宽度] 标定物像素长度: {length_px:.2f} px , "
+                  f"真实长度: {self.topdown_known_length_mm:.2f} mm")
+            print(f"[顶拍/宽度] 比例系数 k : {k:.6f} mm/像素")
+            print(f"[侧拍/高度] 标定物像素高度: {height_px:.2f} px , "
+                  f"真实高度: {self.side_known_height_mm:.2f} mm")
+            print(f"[侧拍/高度] 比例系数 kh: {kh:.6f} mm/像素")
+
+        if kl:
+            result["kl_mm_per_px"] = kl
+            result["side_known_length_mm"] = self.side_known_length_mm
+            result["side_pixel_length"] = side_length_px
+            if self.verbose:
+                print(f"[侧拍/长度] 标定物像素宽度: {side_length_px:.2f} px , "
+                      f"真实长度: {self.side_known_length_mm:.2f} mm")
+                print(f"[侧拍/长度] 比例系数 kl: {kl:.6f} mm/像素")
+                ratio = kl / kh
+                print(f"\n[诊断] kl/kh = {ratio:.4f}")
+                if abs(ratio - 1) > 0.05:
+                    print(f"  ⚠ 水平与竖直方向比例系数相差 {abs(ratio - 1) * 100:.1f}%,"
+                          f"说明侧拍相机确实存在倾斜/畸变,"
+                          f"分开标定是必要的(这正是之前长度偏差的来源)。")
+                else:
+                    print(f"  两个方向比例系数接近,侧拍视角基本正常。")
+        elif self.verbose:
+            print("[侧拍/长度] 未提供 side_known_length_mm,长度将沿用kh换算(可能有系统性偏差)")
+
+        if self.front_png and self.front_known_width_mm:
+            kw, front_width_px = _calibrate_front_from_mask(
+                self.front_png, self.front_known_width_mm
+            )
+            result["kw_mm_per_px"] = kw
+            result["front_known_width_mm"] = self.front_known_width_mm
+            result["front_pixel_width"] = front_width_px
+            if self.verbose:
+                print(f"[正面拍/宽度] 标定物像素宽度: {front_width_px:.2f} px , "
+                      f"真实宽度: {self.front_known_width_mm:.2f} mm")
+                print(f"[正面拍/宽度] 比例系数 kw: {kw:.6f} mm/像素")
+        print("===============================") if self.verbose else None
+
+        return result
+
+
+# ===========================================================================
+# ShoeMeasurer —— 拷贝自 measure_shoe_v2.py,封装为类,debug 参数可选保存结果图
+# ===========================================================================
+
+def _get_vis_background(png_img, original_path):
+    """优先用原图做可视化背景(更真实),没提供原图则把抠图合成到白底上。"""
+    if original_path:
+        bg = imread_unicode(original_path)
+        if bg is None:
+            raise FileNotFoundError(f"无法读取原图: {original_path}")
+        if bg.shape[:2] != png_img.shape[:2]:
+            print(f"警告: 原图尺寸{bg.shape[:2]}与抠图PNG尺寸{png_img.shape[:2]}不一致,"
+                  f"标注框可能对不齐,建议确认两者是否为同一张照片导出。")
+        return bg
+    return composite_on_white(png_img)
+
+
+def _measure_width_front(png_img, vis_bg, kw_mm_per_px):
+    """
+    正面拍图:测宽度(水平像素跨度×kw)。
+    相比顶拍测宽度,正面拍能看到被鞋面遮挡的鞋底外沿部分,对这类鞋型精度明显更高。
+    """
+    mask = mask_from_alpha(png_img)
+    contour = largest_contour(mask)
+    xs = contour[:, 0, 0]
+    left_x, right_x = int(xs.min()), int(xs.max())
+    width_px = right_x - left_x
+    width_mm = width_px * kw_mm_per_px
+
+    vis = vis_bg.copy()
+    ys = contour[:, 0, 1]
+    mid_y = int(np.mean(ys))
+    cv2.drawContours(vis, [contour], -1, (0, 255, 0), 2)
+    cv2.line(vis, (left_x, mid_y), (right_x, mid_y), (255, 0, 0), 2)
+    cv2.circle(vis, (left_x, mid_y), 6, (255, 0, 0), -1)
+    cv2.circle(vis, (right_x, mid_y), 6, (255, 0, 0), -1)
+    cv2.putText(vis, f"W(front)={width_mm:.1f}mm",
+                (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255, 0, 0), 2)
+    return width_mm, vis
+
+
+def _measure_width_topdown(png_img, vis_bg, k_mm_per_px):
+    """顶拍图:只测宽度(minAreaRect短边),不再用顶拍算长度(避免鞋子高度导致的透视外扩误差)"""
+    mask = mask_from_alpha(png_img)
+    contour = largest_contour(mask)
+
+    (cx, cy), (w_px, h_px), angle = cv2.minAreaRect(contour)
+    width_px = min(w_px, h_px)
+    width_mm = width_px * k_mm_per_px
+
+    vis = vis_bg.copy()
+    box = np.intp(cv2.boxPoints(((cx, cy), (w_px, h_px), angle)))
+    cv2.drawContours(vis, [contour], -1, (0, 255, 0), 2)
+    cv2.drawContours(vis, [box], 0, (0, 0, 255), 2)
+    cv2.putText(vis, f"W={width_mm:.1f}mm",
+                (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 255), 2)
+    return width_mm, vis
+
+
+def _measure_length_height_side(png_img, vis_bg, kh_mm_per_px, kl_mm_per_px=None):
+    """
+    侧拍图:测长度(水平像素跨度×kl)和高度(竖直像素跨度×kh)。
+    kl 与 kh 分开使用,因为侧拍相机若有倾斜/畸变,水平与竖直方向比例尺不同。
+    若未提供 kl,则退回沿用 kh,但长度可能有系统性偏差。
+    """
+    if kl_mm_per_px is None:
+        kl_mm_per_px = kh_mm_per_px
+
+    mask = mask_from_alpha(png_img)
+    contour = largest_contour(mask)
+
+    xs = contour[:, 0, 0]
+    ys = contour[:, 0, 1]
+    left_x, right_x = int(xs.min()), int(xs.max())
+    top_y, bottom_y = int(ys.min()), int(ys.max())
+
+    length_px = right_x - left_x
+    height_px = bottom_y - top_y
+    length_mm = length_px * kl_mm_per_px
+    height_mm = height_px * kh_mm_per_px
+
+    vis = vis_bg.copy()
+    mid_y = int(np.mean(ys))
+    mid_x = int(np.mean(xs))
+    cv2.drawContours(vis, [contour], -1, (0, 255, 0), 2)
+    # 长度标注线(水平)
+    cv2.line(vis, (left_x, mid_y), (right_x, mid_y), (255, 0, 0), 2)
+    cv2.circle(vis, (left_x, mid_y), 6, (255, 0, 0), -1)
+    cv2.circle(vis, (right_x, mid_y), 6, (255, 0, 0), -1)
+    # 高度标注线(竖直)
+    cv2.line(vis, (mid_x, top_y), (mid_x, bottom_y), (0, 0, 255), 2)
+    cv2.circle(vis, (mid_x, top_y), 6, (0, 0, 255), -1)
+    cv2.circle(vis, (mid_x, bottom_y), 6, (0, 0, 255), -1)
+    cv2.putText(vis, f"L={length_mm:.1f}mm H={height_mm:.1f}mm",
+                (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 255), 2)
+    return length_mm, height_mm, vis
+
+
+def _annotate_final_summary(vis, length_mm, width_mm, height_mm,
+                            timestamp_str, width_corrected):
+    """
+    在图片左上角统一标出这次测量的完整长/宽/高结果(mm)和时间戳,
+    每张结果图都会加这个统一信息条,方便单独看某一张图时也能知道完整结果。
+    """
+    vis = vis.copy()
+    lines = [
+        f"L={length_mm:.1f}mm  W={width_mm:.1f}mm"
+        f"{' (adjusted)' if width_corrected else ''}  H={height_mm:.1f}mm",
+        f"{timestamp_str}",
+    ]
+    # 用不透明底色条完全盖住图片本身已有的测量标注文字,避免重影
+    box_h = 140 * len(lines) + 20
+    cv2.rectangle(vis, (0, 0), (580, box_h), (255, 255, 255), -1)
+
+    y = 140
+    for line in lines:
+        cv2.putText(vis, line, (15, y), cv2.FONT_HERSHEY_SIMPLEX, 4.8,
+                    (0, 0, 200), 2, cv2.LINE_AA)
+        y += 140
+    return vis
+
+
+class ShoeMeasurer:
+    """
+    测量(类封装版)。new 时传入各视角抠图PNG、标定结果及可选修正参数,
+    调用 measure() 返回长/宽/高结果 dict。
+    debug=True 时把 result_*.jpg 标注图保存到 output_dir 目录。
+
+    参数(对应原 measure_shoe_v2.py 的命令行参数):
+        topdown_png         顶拍抠图PNG(带alpha)
+        side_png            水平拍抠图PNG(带alpha)
+        front_png           可选,正面拍抠图PNG(带alpha),提供后宽度改用正面拍测量
+        topdown_original    可选,顶拍原图(可视化背景)
+        side_original       可选,水平拍原图(可视化背景)
+        calib               标定结果:Calibrator.calibrate() 返回的 dict,
+                            或 calib.json 文件路径(str),默认 "calib.json"
+        width_slope         可选,宽度修正的高度斜率(mm宽度/mm高度)
+        width_scale0        可选,宽度在零高度时的固有放大倍数
+                            (以上两个配合做顶拍宽度透视修正)
+        length_factor       可选,长度修正因子(测出值除以此数)
+        debug               可选,是否保存 result_*.jpg 标注图(默认False)
+        output_dir          debug保存目录(默认当前目录)
+        verbose             是否打印测量过程/结果(默认True)
+    """
+
+    def __init__(self, topdown_png, side_png, front_png=None,
+                 topdown_original=None, side_original=None,
+                 calib="calib.json",
+                 width_slope=None, width_scale0=None,
+                 length_factor=None,
+                 debug=False, output_dir=".",
+                 verbose=True):
+        self.topdown_png = topdown_png
+        self.side_png = side_png
+        self.front_png = front_png
+        self.topdown_original = topdown_original
+        self.side_original = side_original
+        self.calib = calib
+        self.width_slope = width_slope
+        self.width_scale0 = width_scale0
+        self.length_factor = length_factor
+        self.debug = debug
+        self.output_dir = output_dir
+        self.verbose = verbose
+
+    def measure(self):
+        """
+        执行测量,返回结果 dict:
+            length_mm / width_mm / height_mm   最终长/宽/高(mm)
+            length_mm_raw / width_mm_raw       修正前的原始测量值
+            width_from_front_mm                正面拍宽度(未用正面拍时为None)
+            width_corrected                    宽度是否经过修正
+            result_images                      debug=True 时保存的标注图路径列表
+        """
+        calib = self.calib if isinstance(self.calib, dict) else load_json(self.calib)
+        k = calib["k_mm_per_px"]
+        kh = calib["kh_mm_per_px"]
+        kl = calib.get("kl_mm_per_px")
+        if kl is None and self.verbose:
+            print("提示: 标定结果里没有 kl_mm_per_px(长度方向系数),长度将沿用kh换算。"
+                  "建议在 Calibrator 里提供 side_known_length_mm 以消除长度系统性偏差。")
+        kw = calib.get("kw_mm_per_px")
+
+        top_png = imread_unicode_unchanged(self.topdown_png)
+        side_png = imread_unicode_unchanged(self.side_png)
+        if top_png is None:
+            raise FileNotFoundError(f"无法读取图片: {self.topdown_png}")
+        if side_png is None:
+            raise FileNotFoundError(f"无法读取图片: {self.side_png}")
+
+        top_bg = _get_vis_background(top_png, self.topdown_original)
+        side_bg = _get_vis_background(side_png, self.side_original)
+
+        if self.verbose:
+            print("正在处理顶拍抠图(测宽度)...")
+        width_mm_raw, vis_top = _measure_width_topdown(top_png, top_bg, k)
+
+        if self.verbose:
+            print("正在处理侧拍抠图(测长度和高度)...")
+        length_mm, height_mm, vis_side = _measure_length_height_side(side_png, side_bg, kh, kl)
+
+        vis_front = None
+        width_from_front = None
+        if self.front_png:
+            if not kw:
+                if self.verbose:
+                    print("警告: 提供了 front_png 但标定结果里没有 kw_mm_per_px,"
+                          "请先在 Calibrator 里提供 front_png/front_known_width_mm。"
+                          "本次仍使用顶拍宽度。")
+            else:
+                front_png = imread_unicode_unchanged(self.front_png)
+                if front_png is None:
+                    raise FileNotFoundError(f"无法读取图片: {self.front_png}")
+                front_bg = _get_vis_background(front_png, None)
+                if self.verbose:
+                    print("正在处理正面拍抠图(测宽度,能看到顶拍被遮挡的鞋底外沿)...")
+                    width_from_front, vis_front = _measure_width_front(front_png, front_bg, kw)
+                    print(f"  顶拍测宽度(可能被遮挡偏小): {width_mm_raw:.2f}mm")
+                    print(f"  正面拍测宽度(更准): {width_from_front:.2f}mm")
+
+        width_mm = width_from_front if width_from_front is not None else width_mm_raw
+        width_corrected = False
+
+        length_mm_raw = length_mm
+        if self.length_factor:
+            length_mm = length_mm_raw / self.length_factor
+            if self.verbose:
+                print(f"\n[长度修正] 因子={self.length_factor:.6f}: "
+                      f"{length_mm_raw:.2f}mm -> {length_mm:.2f}mm")
+
+        if width_from_front is None and self.width_slope is not None and self.width_scale0 is not None:
+            # 修正模型(由标准块实验拟合得到,两部分),仅用于顶拍宽度:
+            #   1) 减去随高度线性递增的透视放大量: width_slope * height
+            #   2) 除以零高度时的固有放大倍数: width_scale0
+            # 注意: 这个修正解决的是"透视外扩",不解决"鞋面遮挡鞋底"问题,
+            # 后者只能靠 front_png 正面拍视角解决。
+            width_mm = (width_mm_raw - self.width_slope * height_mm) / self.width_scale0
+            width_corrected = True
+            if self.verbose:
+                print(f"\n[顶拍宽度透视修正] 斜率={self.width_slope:.6f}, "
+                      f"零高度放大={self.width_scale0:.4f}, 物体高度={height_mm:.1f}mm")
+                print(f"[顶拍宽度透视修正] 修正前: {width_mm_raw:.2f}mm -> 修正后: {width_mm:.2f}mm")
+        elif width_from_front is not None:
+            width_corrected = True  # 正面拍宽度本身就是"已修正"(更准)的结果
+
+        if self.verbose:
+            print("\n========== 测量结果 ==========")
+            print(f"长 (length): {length_mm:.2f} mm")
+            print(f"宽 (width) : {width_mm:.2f} mm"
+                  + ("  (已修正)" if width_corrected else "  (未修正)"))
+            print(f"高 (height): {height_mm:.2f} mm")
+            print("===============================")
+
+        timestamp_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+        timestamp_tag = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
+
+        outputs = []
+        if self.debug:
+            vis_top_final = _annotate_final_summary(
+                vis_top, length_mm, width_mm, height_mm, timestamp_str, width_corrected)
+            vis_side_final = _annotate_final_summary(
+                vis_side, length_mm, width_mm, height_mm, timestamp_str, width_corrected)
+
+            topdown_out = os.path.join(self.output_dir, f"result_topdown_{timestamp_tag}.jpg")
+            side_out = os.path.join(self.output_dir, f"result_side_{timestamp_tag}.jpg")
+            imwrite_unicode(topdown_out, vis_top_final)
+            imwrite_unicode(side_out, vis_side_final)
+            outputs = [topdown_out, side_out]
+
+            if vis_front is not None:
+                vis_front_final = _annotate_final_summary(
+                    vis_front, length_mm, width_mm, height_mm, timestamp_str, width_corrected)
+                front_out = os.path.join(self.output_dir, f"result_front_{timestamp_tag}.jpg")
+                imwrite_unicode(front_out, vis_front_final)
+                outputs.append(front_out)
+
+            if self.verbose:
+                print(f"标注结果图已保存: {', '.join(outputs)}")
+
+        return {
+            "length_mm": length_mm,
+            "width_mm": width_mm,
+            "height_mm": height_mm,
+            "length_mm_raw": length_mm_raw,
+            "width_mm_raw": width_mm_raw,
+            "width_from_front_mm": width_from_front,
+            "width_corrected": width_corrected,
+            "result_images": outputs,
+        }

+ 182 - 0
python/service/measure/turntable_calib_utils.py

@@ -0,0 +1,182 @@
+# -*- coding: utf-8 -*-
+"""
+共享工具函数:
+    - imread_unicode: 兼容 Windows 中文路径的图片读取
+    - segment_object_grabcut: 用 GrabCut 从背景(转盘)中分割出物体,返回二值掩码
+    - pick_two_points: 交互式点选2个点(用于标定时点选已知长度的两端)
+    - select_roi_unicode: 封装 cv2.selectROI,用于框选物体大致范围供 GrabCut 使用
+"""
+import cv2
+import numpy as np
+import json
+import os
+
+
+def imread_unicode(path):
+    """兼容 Windows 下含中文/特殊字符路径的图片读取(cv2.imread 对非ASCII路径常读取失败)。"""
+    data = np.fromfile(path, dtype=np.uint8)
+    img = cv2.imdecode(data, cv2.IMREAD_COLOR)
+    return img
+
+
+def imread_unicode_unchanged(path):
+    """
+    同上,但保留 alpha 通道(用于读取抠图后的透明背景PNG)。
+    返回的图像若原图有透明通道,shape为 (H,W,4),否则为 (H,W,3)。
+    """
+    data = np.fromfile(path, dtype=np.uint8)
+    img = cv2.imdecode(data, cv2.IMREAD_UNCHANGED)
+    return img
+
+
+def mask_from_alpha(png_img, alpha_thresh=127):
+    """
+    从抠图PNG的alpha通道直接生成二值掩码,不需要GrabCut/手动框选。
+    要求 png_img 是4通道(BGRA)图像。
+    """
+    if png_img is None:
+        raise RuntimeError("图片读取失败(为None)。")
+    if png_img.ndim != 3 or png_img.shape[2] != 4:
+        raise RuntimeError(
+            "该图片没有透明通道(不是4通道BGRA的PNG),无法从alpha直接取掩码。"
+            "请确认传入的是抠图后的透明背景PNG,而不是普通jpg/无透明通道的png。"
+        )
+    alpha = png_img[:, :, 3]
+    mask = np.where(alpha > alpha_thresh, 255, 0).astype(np.uint8)
+    # 轻微开闭运算去除抠图边缘的毛刺噪点,不改变整体轮廓
+    kernel = np.ones((3, 3), np.uint8)
+    mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
+    mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
+    return mask
+
+
+def composite_on_white(bgra_img):
+    """
+    把带透明通道的抠图结果合成到白色背景上,得到一张普通3通道BGR图,
+    用于在没有提供原图时也能生成清晰的可视化标注图。
+    """
+    b, g, r, a = cv2.split(bgra_img.astype(np.float32))
+    alpha = a / 255.0
+    white = np.full_like(b, 255.0)
+    out_b = b * alpha + white * (1 - alpha)
+    out_g = g * alpha + white * (1 - alpha)
+    out_r = r * alpha + white * (1 - alpha)
+    return cv2.merge([out_b, out_g, out_r]).astype(np.uint8)
+
+
+def imwrite_unicode(path, img):
+    """兼容 Windows 中文路径的图片写入。"""
+    ext = os.path.splitext(path)[1]
+    ok, buf = cv2.imencode(ext, img)
+    if ok:
+        buf.tofile(path)
+    return ok
+
+
+def save_json(path, data):
+    with open(path, "w", encoding="utf-8") as f:
+        json.dump(data, f, ensure_ascii=False, indent=2)
+
+
+def load_json(path):
+    with open(path, "r", encoding="utf-8") as f:
+        return json.load(f)
+
+
+def select_roi_unicode(window_name, img, max_display_width=1200):
+    """
+    框选物体大致范围(拖出一个矩形框住鞋子,尽量贴合但不用太精确)。
+    大图会先等比缩小显示,避免窗口超出屏幕,选完自动换算回原图坐标。
+    操作:鼠标拖拽画框 -> 按空格/回车确认 -> 按c取消重选。
+    """
+    h, w = img.shape[:2]
+    scale = min(1.0, max_display_width / w)
+    disp = cv2.resize(img, (int(w * scale), int(h * scale))) if scale < 1.0 else img.copy()
+
+    print(f"[{window_name}] 请用鼠标拖拽框选出物体大致范围(不用太精确,比物体略大即可),"
+          f"框完按空格/回车确认。")
+    r = cv2.selectROI(window_name, disp, showCrosshair=True, fromCenter=False)
+    cv2.destroyWindow(window_name)
+    x, y, rw, rh = r
+    if rw == 0 or rh == 0:
+        raise RuntimeError("未选择有效区域,请重新运行并框选物体。")
+    # 换算回原图坐标
+    return (int(x / scale), int(y / scale), int(rw / scale), int(rh / scale))
+
+
+def segment_object_grabcut(img, rect, iter_count=5, margin=0.06):
+    """
+    用 GrabCut 从背景(转盘/桌面)中把物体分割出来。
+    rect: (x, y, w, h) 物体大致所在的框选区域(来自 select_roi_unicode)
+    margin: 在框选区域基础上再收缩一点作为"确定前景"的种子区域,提高分割稳定性
+    返回: 二值掩码(255=物体, 0=背景),与原图同尺寸
+    """
+    mask = np.zeros(img.shape[:2], np.uint8)
+    bgd_model = np.zeros((1, 65), np.float64)
+    fgd_model = np.zeros((1, 65), np.float64)
+
+    cv2.grabCut(img, mask, rect, bgd_model, fgd_model, iter_count, cv2.GC_INIT_WITH_RECT)
+    mask2 = np.where((mask == cv2.GC_FGD) | (mask == cv2.GC_PR_FGD), 255, 0).astype(np.uint8)
+
+    # 形态学开闭运算去除噪点、填补小空洞
+    kernel = np.ones((5, 5), np.uint8)
+    mask2 = cv2.morphologyEx(mask2, cv2.MORPH_OPEN, kernel)
+    mask2 = cv2.morphologyEx(mask2, cv2.MORPH_CLOSE, kernel)
+    return mask2
+
+
+def largest_contour(mask):
+    """从二值掩码中取面积最大的轮廓(即物体主体,排除噪点)。"""
+    contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
+    if not contours:
+        raise RuntimeError("未能从分割结果中找到有效轮廓,请重新框选或检查背景是否干净。")
+    return max(contours, key=cv2.contourArea)
+
+
+class TwoPointPicker:
+    """
+    交互式点选2个点,用于标定时点出已知长度参照物的两端
+    (比如标定尺的0刻度和300mm刻度,或标定块的顶部和底部)。
+    """
+    def __init__(self, window_name, img, labels=("点1", "点2"), max_display_width=1400):
+        self.window_name = window_name
+        self.orig_img = img
+        h, w = img.shape[:2]
+        self.scale = min(1.0, max_display_width / w)
+        self.img = cv2.resize(img, (int(w * self.scale), int(h * self.scale))) \
+            if self.scale < 1.0 else img.copy()
+        self.display = self.img.copy()
+        self.points = []
+        self.labels = labels
+
+    def _on_mouse(self, event, x, y, flags, param):
+        if event == cv2.EVENT_LBUTTONDOWN and len(self.points) < 2:
+            self.points.append((x, y))
+            self._redraw()
+
+    def _redraw(self):
+        self.display = self.img.copy()
+        for i, (x, y) in enumerate(self.points):
+            cv2.circle(self.display, (x, y), 6, (0, 0, 255), -1)
+            cv2.putText(self.display, self.labels[i], (x + 8, y - 8),
+                        cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
+        if len(self.points) == 2:
+            cv2.line(self.display, self.points[0], self.points[1], (0, 255, 0), 2)
+        cv2.imshow(self.window_name, self.display)
+
+    def run(self):
+        cv2.namedWindow(self.window_name, cv2.WINDOW_NORMAL)
+        cv2.setMouseCallback(self.window_name, self._on_mouse)
+        print(f"请依次点击: {self.labels[0]} -> {self.labels[1]}")
+        print("点完2个点后按任意键确认,按 r 重来。")
+        self._redraw()
+        while True:
+            key = cv2.waitKey(20) & 0xFF
+            if key == ord('r'):
+                self.points = []
+                self._redraw()
+            elif len(self.points) == 2 and key != 255:
+                break
+        cv2.destroyAllWindows()
+        # 换算回原图坐标
+        return [(x / self.scale, y / self.scale) for x, y in self.points]