# -*- 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, }