Files
ozone-tech_owl_prime/cv/measure.py
root 21e83be26e release: consolidate web simulation and real CV prototype
Add cleaned RealSense/OpenCV CV under cv/ with secret-free example
config, MQTT disabled by default, and canonical README coverage for
web + CV relationship without claiming production integration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:23:05 +02:00

720 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Сегментация и измерение по ТЗ трека 3 — без эвристик «подкрутки»."""
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Optional, Tuple
import cv2
import numpy as np
@dataclass
class ObjectMeasurement:
length_mm: float
width_mm: float
height_mm: float
circle_ratio: float # итог для классификатора: max устойчивых сечений
area_px: int
centroid_px: Tuple[int, int]
contour: np.ndarray
mask: np.ndarray
top_ratio: float = 0.0 # rin/rout вида сверху
section_ratio: float = 0.0 # лучший устойчивый 3D-срез
source: str = "depth" # "depth" | "rgb" (плоский товар, найден по RGB)
clipped_by_frame: bool = False # объект упирается в край кадра → габарит неполный
def is_plausible_measurement(
m: ObjectMeasurement,
min_footprint_mm: float = 15.0,
) -> bool:
"""Отсев шума depth/RGB до классификации (не путать с ТЗ-негабаритом).
Мелкие пятна и «0×0×0 мм» не должны попадать в трекер — иначе
check_size(<10 мм) даёт ложный класс C.
"""
L, W, H = float(m.length_mm), float(m.width_mm), float(m.height_mm)
if L <= 1.5 or W <= 1.5:
return False
if int(m.area_px) < 120:
return False
a, b, c = sorted((L, W, H), reverse=True)
if a < min_footprint_mm:
return False
if c <= 0.5:
return False
# RGB-шум: нет высоты и крошечное пятно на ленте
if getattr(m, "source", "depth") == "rgb" and H < 2.0 and a < 45.0:
return False
return True
def mask_iou(a: np.ndarray, b: np.ndarray) -> float:
"""Классический IoU масок."""
inter = int(np.count_nonzero((a > 0) & (b > 0)))
if inter == 0:
return 0.0
ua = int(np.count_nonzero(a > 0))
ub = int(np.count_nonzero(b > 0))
return inter / float(ua + ub - inter)
def mask_overlap_min(a: np.ndarray, b: np.ndarray) -> float:
"""Доля пересечения относительно меньшей маски (0..1).
≥0.5 ≈ «хотя бы половина одного объекта лежит на другом».
Удобнее IoU, когда кусок намного меньше целого.
"""
inter = int(np.count_nonzero((a > 0) & (b > 0)))
if inter == 0:
return 0.0
ua = int(np.count_nonzero(a > 0))
ub = int(np.count_nonzero(b > 0))
return inter / float(max(1, min(ua, ub)))
def merge_overlapping_masks(
items: List[Tuple[np.ndarray, np.ndarray]],
overlap_thr: float = 0.5,
near_gap_px: int = 14,
) -> List[Tuple[np.ndarray, np.ndarray]]:
"""Склеить контуры при IoU/пересечении ≥ thr или узкой дыре depth (near_gap)."""
if len(items) <= 1:
return items
items = sorted(items, key=lambda ic: cv2.contourArea(ic[1]), reverse=True)
used = [False] * len(items)
out: List[Tuple[np.ndarray, np.ndarray]] = []
def _near(a: np.ndarray, b: np.ndarray) -> bool:
xa, ya, wa, ha = cv2.boundingRect(a)
xb, yb, wb, hb = cv2.boundingRect(b)
g = int(near_gap_px)
return not (
xa + wa + g < xb or xb + wb + g < xa or ya + ha + g < yb or yb + hb + g < ya
)
for i, (mask_i, _) in enumerate(items):
if used[i]:
continue
merged = mask_i.copy()
used[i] = True
changed = True
while changed:
changed = False
for j, (mask_j, _) in enumerate(items):
if used[j]:
continue
hit = (
mask_overlap_min(merged, mask_j) >= overlap_thr
or mask_iou(merged, mask_j) >= overlap_thr
or _near(merged, mask_j)
)
if hit:
merged = cv2.bitwise_or(merged, mask_j)
used[j] = True
changed = True
k = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
merged = cv2.morphologyEx(merged, cv2.MORPH_CLOSE, k, iterations=1)
contours, _ = cv2.findContours(merged, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
continue
contour = max(contours, key=cv2.contourArea)
clean = np.zeros_like(merged)
cv2.drawContours(clean, [contour], -1, 255, thickness=-1)
out.append((clean, contour))
return out
def merge_overlapping_measurements(
measurements: List[ObjectMeasurement],
overlap_thr: float = 0.5,
) -> List[ObjectMeasurement]:
"""Склеить измерения с пересекающимися масками (оставить более крупное)."""
if len(measurements) <= 1:
return measurements
ms = sorted(measurements, key=lambda m: m.area_px, reverse=True)
kept: List[ObjectMeasurement] = []
for m in ms:
drop = False
for k in kept:
if m.mask.shape != k.mask.shape:
continue
if mask_overlap_min(m.mask, k.mask) >= overlap_thr or mask_iou(m.mask, k.mask) >= overlap_thr:
drop = True
break
if not drop:
kept.append(m)
return kept
def apply_roi_margin(
mask: np.ndarray,
margin: Optional[dict] = None,
) -> np.ndarray:
"""Обнулить края кадра (ролики, борта, плата), доли 0..1 от H/W."""
if not margin:
return mask
h, w = mask.shape[:2]
top = int(h * float(margin.get("top", 0)))
bottom = int(h * float(margin.get("bottom", 0)))
left = int(w * float(margin.get("left", 0)))
right = int(w * float(margin.get("right", 0)))
out = mask.copy()
if top > 0:
out[:top, :] = 0
if bottom > 0:
out[h - bottom :, :] = 0
if left > 0:
out[:, :left] = 0
if right > 0:
out[:, w - right :] = 0
return out
def contour_touches_border(
contour: np.ndarray,
shape: Tuple[int, ...],
margin_px: int = 3,
roi_margin: Optional[dict] = None,
) -> bool:
"""True если контур упирается в край кадра/ROI — реальный размер может быть больше."""
h, w = int(shape[0]), int(shape[1])
top = int(h * float((roi_margin or {}).get("top", 0)))
bottom = int(h * float((roi_margin or {}).get("bottom", 0)))
left = int(w * float((roi_margin or {}).get("left", 0)))
right = int(w * float((roi_margin or {}).get("right", 0)))
y0, y1 = top + margin_px, h - bottom - 1 - margin_px
x0, x1 = left + margin_px, w - right - 1 - margin_px
pts = contour.reshape(-1, 2)
xs, ys = pts[:, 0], pts[:, 1]
return bool(
(xs <= x0).any()
or (xs >= x1).any()
or (ys <= y0).any()
or (ys >= y1).any()
)
def segment_objects(
depth_mm: np.ndarray,
belt_distance_mm: float,
belt_tolerance_mm: float = 25.0,
min_object_height_mm: float = 5.0,
min_area_px: int = 800,
background_mm: Optional[np.ndarray] = None,
max_objects: int = 3,
roi_margin: Optional[dict] = None,
) -> List[Tuple[np.ndarray, np.ndarray]]:
"""Все объекты в кадре (крупнейшие первыми), до max_objects штук.
Порог высоты — как раньше (строгий): мягкий «ореол» раздувал маску на ленту
и ломал габариты/круг → путаница B/C/D.
"""
valid = (depth_mm > 50) & (depth_mm < 5000)
hmin = float(min_object_height_mm)
if background_mm is not None:
bg = background_mm.astype(np.float32)
d = depth_mm.astype(np.float32)
raised = valid & (bg > 50) & (d < bg - hmin)
near_belt_band = d > (bg - 520.0)
else:
raised = valid & (depth_mm < (belt_distance_mm - hmin))
near_belt_band = depth_mm > (belt_distance_mm - 450)
mask = (raised & near_belt_band).astype(np.uint8) * 255
mask = apply_roi_margin(mask, roi_margin)
k_open = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
k_close = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k_open, iterations=1)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k_close, iterations=2)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
frame_area = mask.shape[0] * mask.shape[1]
raw: List[Tuple[np.ndarray, np.ndarray]] = []
for contour in sorted(contours, key=cv2.contourArea, reverse=True):
area = cv2.contourArea(contour)
# крупные товары могут занимать почти весь кадр — не отсекать как шум
if area < min_area_px or area > frame_area * 0.92:
continue
clean = np.zeros_like(mask)
cv2.drawContours(clean, [contour], -1, 255, thickness=-1)
raw.append((clean, contour))
# только явное пересечение / узкая щель — не склеивать соседние товары
merged = merge_overlapping_masks(raw, overlap_thr=0.5, near_gap_px=14)
return merged[:max_objects]
def segment_object(
depth_mm: np.ndarray,
belt_distance_mm: float,
belt_tolerance_mm: float = 25.0,
min_object_height_mm: float = 5.0,
min_area_px: int = 800,
background_mm: Optional[np.ndarray] = None,
roi_margin: Optional[dict] = None,
) -> Optional[Tuple[np.ndarray, np.ndarray]]:
"""Крупнейший объект (для main.py/calibrate.py — один товар в накопителе)."""
objs = segment_objects(
depth_mm,
belt_distance_mm,
belt_tolerance_mm=belt_tolerance_mm,
min_object_height_mm=min_object_height_mm,
min_area_px=min_area_px,
background_mm=background_mm,
max_objects=1,
roi_margin=roi_margin,
)
return objs[0] if objs else None
def _pixel_to_xy_mm(
u: float, v: float, z_mm: float, fx: float, fy: float, cx: float, cy: float
) -> Tuple[float, float]:
return (u - cx) * z_mm / fx, (v - cy) * z_mm / fy
def measure_object(
depth_mm: np.ndarray,
mask: np.ndarray,
contour: np.ndarray,
belt_distance_mm: float,
fx: float,
fy: float,
cx: float,
cy: float,
background_mm: Optional[np.ndarray] = None,
min_object_height_mm: float = 5.0,
roi_margin: Optional[dict] = None,
) -> Optional[ObjectMeasurement]:
ys, xs = np.where(mask > 0)
if xs.size < 50:
return None
z_vals = depth_mm[ys, xs].astype(np.float32)
z_vals = z_vals[z_vals > 0]
if z_vals.size < 50:
return None
z_med = float(np.median(z_vals))
# высота относительно локального фона (платформа/лента под объектом)
belt_local = belt_distance_mm
if background_mm is not None:
bg_vals = background_mm[ys, xs].astype(np.float32)
bg_vals = bg_vals[bg_vals > 50]
if bg_vals.size >= 50:
belt_local = float(np.median(bg_vals))
height_mm = max(0.0, belt_local - z_med)
pts_mm = []
for p in contour.reshape(-1, 2):
u, v = float(p[0]), float(p[1])
x, y = _pixel_to_xy_mm(u, v, z_med, fx, fy, cx, cy)
pts_mm.append([x, y])
pts_mm = np.asarray(pts_mm, dtype=np.float32)
if pts_mm.shape[0] < 5:
return None
rect = cv2.minAreaRect(pts_mm.reshape(-1, 1, 2))
rw, rh = rect[1]
length_mm = float(max(rw, rh))
width_mm = float(min(rw, rh))
touches_edge = contour_touches_border(contour, mask.shape, roi_margin=roi_margin)
# Негабарит «не влезает в кадр» только если реально занимает большую долю FOV.
# Лежачая бутылка может чуть касаться ROI — это не повод форсировать 500 мм.
span_x = float(xs.max() - xs.min())
span_y = float(ys.max() - ys.min())
mh = float((roi_margin or {}).get("top", 0.0)) + float((roi_margin or {}).get("bottom", 0.0))
mw = float((roi_margin or {}).get("left", 0.0)) + float((roi_margin or {}).get("right", 0.0))
usable_w = mask.shape[1] * max(0.5, 1.0 - mw)
usable_h = mask.shape[0] * max(0.5, 1.0 - mh)
spans_frame = (span_x >= 0.72 * usable_w) or (span_y >= 0.72 * usable_h)
clipped = bool(touches_edge and spans_frame)
# отсев шума ленты / руки на краю (низкий «холм» большой площади → не товар)
# но не отсекаем крупные обрезанные объекты — они уйдут в C
if not clipped and height_mm < 30.0 and max(length_mm, width_mm) > 150.0:
return None
# жёсткий пол — иначе складки ленты дают ложный C
if height_mm < max(20.0, float(min_object_height_mm)):
return None
# раньше >520 отбрасывали → ложный B на негабарите в FOV;
# оставляем измерение: classify отправит в C (>450)
if max(length_mm, width_mm) > 2000.0 and not clipped:
return None
# объект не помещается в кадр → габарит занижен; форсируем > max ТЗ
if clipped:
length_mm = max(length_mm, 500.0)
top_ratio = rin_rout(pts_mm)
section_ratio = 0.0
cloud = _point_cloud(depth_mm, mask, fx, fy, cx, cy)
if cloud is not None:
section_ratio = robust_section_ratio(cloud)
# ТЗ: круг в любом сечении. Один шумный 3D-срез не считаем:
# D только если top>=0.8 ИЛИ ≥2 среза >=0.8 (внутри robust_section_ratio).
circle_ratio = max(top_ratio, section_ratio)
if clipped:
# обрезанный негабарит не классифицируем по кругу
circle_ratio = min(circle_ratio, 0.5)
m = cv2.moments(contour)
if m["m00"] > 0:
cx_px = int(m["m10"] / m["m00"])
cy_px = int(m["m01"] / m["m00"])
else:
cx_px, cy_px = int(xs.mean()), int(ys.mean())
return ObjectMeasurement(
length_mm=length_mm,
width_mm=width_mm,
height_mm=height_mm,
circle_ratio=float(circle_ratio),
area_px=int(xs.size),
centroid_px=(cx_px, cy_px),
contour=contour,
mask=mask,
top_ratio=float(top_ratio),
section_ratio=float(section_ratio),
clipped_by_frame=bool(clipped),
)
def segment_rgb_objects(
color_bgr: np.ndarray,
color_bg_bgr: np.ndarray,
min_area_px: int = 800,
diff_threshold: int = 35,
max_objects: int = 3,
exclude_mask: Optional[np.ndarray] = None,
) -> List[Tuple[np.ndarray, np.ndarray]]:
"""Плоские товары (телефон и т.п.) по разнице с RGB-фоном пустой ленты.
exclude_mask — зоны, уже найденные по depth (не дублируем объекты).
Тени (пропорциональное затемнение каналов) отбрасываются.
"""
if color_bgr.shape != color_bg_bgr.shape:
return []
fg = color_bgr.astype(np.float32)
bg = color_bg_bgr.astype(np.float32)
gray = np.max(np.abs(fg - bg), axis=2).astype(np.uint8)
_, m = cv2.threshold(gray, int(diff_threshold), 255, cv2.THRESH_BINARY)
ratio = (fg + 8.0) / (bg + 8.0)
r_med = np.median(ratio, axis=2)
r_spread = np.max(ratio, axis=2) - np.min(ratio, axis=2)
is_shadow = (r_med < 0.93) & (r_med > 0.38) & (r_spread < 0.14)
m[is_shadow] = 0
k = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
m = cv2.morphologyEx(m, cv2.MORPH_OPEN, k, iterations=1)
m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, k, iterations=2)
if exclude_mask is not None:
excl = cv2.dilate(exclude_mask, cv2.getStructuringElement(cv2.MORPH_RECT, (31, 31)))
m[excl > 0] = 0
contours, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
out: List[Tuple[np.ndarray, np.ndarray]] = []
for contour in sorted(contours, key=cv2.contourArea, reverse=True):
if cv2.contourArea(contour) < min_area_px or len(out) >= max_objects:
break
clean = np.zeros_like(m)
cv2.drawContours(clean, [contour], -1, 255, thickness=-1)
out.append((clean, contour))
return out
def measure_flat_object(
depth_mm: np.ndarray,
mask: np.ndarray,
contour: np.ndarray,
belt_distance_mm: float,
fx: float,
fy: float,
cx: float,
cy: float,
background_mm: Optional[np.ndarray] = None,
color_bgr: Optional[np.ndarray] = None,
color_bg_bgr: Optional[np.ndarray] = None,
) -> Optional[ObjectMeasurement]:
"""Измерение товара, найденного по RGB: без отсева по минимальной высоте."""
ys, xs = np.where(mask > 0)
if xs.size < 50:
return None
z_plane = belt_distance_mm
if background_mm is not None:
bg_vals = background_mm[ys, xs].astype(np.float32)
bg_vals = bg_vals[bg_vals > 50]
if bg_vals.size >= 50:
z_plane = float(np.median(bg_vals))
z_vals = depth_mm[ys, xs].astype(np.float32)
z_vals = z_vals[z_vals > 0]
height_mm = max(0.0, z_plane - float(np.median(z_vals))) if z_vals.size >= 50 else 0.0
pts_mm = []
for p in contour.reshape(-1, 2):
x, y = _pixel_to_xy_mm(float(p[0]), float(p[1]), z_plane, fx, fy, cx, cy)
pts_mm.append([x, y])
pts_mm = np.asarray(pts_mm, dtype=np.float32)
if pts_mm.shape[0] < 3:
return None
rect = cv2.minAreaRect(pts_mm.reshape(-1, 1, 2))
rw, rh = rect[1]
length_mm = float(max(rw, rh))
width_mm = float(min(rw, rh))
if max(length_mm, width_mm) > 520.0 or max(length_mm, width_mm) < 5.0:
return None
top_ratio = rin_rout(pts_mm)
m = cv2.moments(contour)
if m["m00"] > 0:
cx_px, cy_px = int(m["m10"] / m["m00"]), int(m["m01"] / m["m00"])
else:
cx_px, cy_px = int(xs.mean()), int(ys.mean())
return ObjectMeasurement(
length_mm=length_mm,
width_mm=width_mm,
height_mm=float(height_mm),
circle_ratio=float(top_ratio),
area_px=int(xs.size),
centroid_px=(cx_px, cy_px),
contour=contour,
mask=mask,
top_ratio=float(top_ratio),
section_ratio=0.0,
source="rgb",
)
def rin_rout(pts_xy: np.ndarray) -> float:
"""ТЗ: r_in / r_out по выпуклой оболочке сечения."""
pts = np.asarray(pts_xy, dtype=np.float32).reshape(-1, 2)
if pts.shape[0] < 3:
return 0.0
hull = cv2.convexHull(pts.reshape(-1, 1, 2))
hull_pts = hull.reshape(-1, 2)
if hull_pts.shape[0] < 3:
return 0.0
(_center, r_out) = cv2.minEnclosingCircle(hull)
r_out = float(r_out)
if r_out < 1e-6:
return 0.0
r_in = _inscribed_radius_mm(hull_pts)
if r_in <= 0:
return 0.0
return float(np.clip(r_in / r_out, 0.0, 1.0))
def robust_section_ratio(cloud: np.ndarray, thr: float = 0.8) -> float:
"""
Поперечные срезы. Из логов: у круга часто 12 среза ≥0.85, иногда медиана падает.
- ≥1 срез с score≥0.85 → принимаем (уверенный круг/дуга);
- иначе ≥2 среза ≥0.8 → max;
- иначе медиана (антишум для коробки).
"""
ratios = _section_ratios_3d(cloud)
if not ratios:
return 0.0
very = [r for r in ratios if r >= 0.85]
if very:
return float(max(very))
strong = [r for r in ratios if r >= thr]
if len(strong) >= 2:
return float(max(strong))
return float(np.median(ratios))
def _point_cloud(
depth_mm: np.ndarray,
mask: np.ndarray,
fx: float,
fy: float,
cx: float,
cy: float,
) -> Optional[np.ndarray]:
ys, xs = np.where(mask > 0)
if xs.size < 150:
return None
z = depth_mm[ys, xs].astype(np.float32)
ok = (z > 50) & (z < 5000)
xs, ys, z = xs[ok], ys[ok], z[ok]
if xs.size < 150:
return None
# детерминированный даунсэмпл (без random)
if xs.size > 4000:
step = int(np.ceil(xs.size / 4000))
xs, ys, z = xs[::step], ys[::step], z[::step]
X = (xs.astype(np.float32) - cx) * z / fx
Y = (ys.astype(np.float32) - cy) * z / fy
return np.column_stack([X, Y, z]).astype(np.float32)
def _section_ratios_3d(cloud: np.ndarray) -> List[float]:
mean = cloud.mean(axis=0)
centered = cloud - mean
try:
_u, s, vt = np.linalg.svd(centered, full_matrices=False)
except np.linalg.LinAlgError:
return []
out: List[float] = []
# только вдоль самой длинной оси — поперечные сечения цилиндра/коробки
for axis_i in range(min(1, vt.shape[0])):
if float(s[axis_i]) < 1e-6:
continue
axis = vt[axis_i]
axis = axis / (np.linalg.norm(axis) + 1e-9)
along = centered @ axis
ref = np.array([0.0, 0.0, 1.0], dtype=np.float32)
if abs(float(np.dot(axis, ref))) > 0.9:
ref = np.array([1.0, 0.0, 0.0], dtype=np.float32)
u = np.cross(axis, ref)
u /= np.linalg.norm(u) + 1e-9
v = np.cross(axis, u)
a0, a1 = float(np.percentile(along, 15)), float(np.percentile(along, 85))
if a1 - a0 < 10.0:
continue
for t in (0.2, 0.35, 0.5, 0.65, 0.8):
ca = a0 + t * (a1 - a0)
half = max(4.0, 0.06 * (a1 - a0))
band = np.abs(along - ca) <= half
if int(band.sum()) < 40:
continue
pts = centered[band]
sec = np.column_stack([pts @ u, pts @ v]).astype(np.float32)
out.append(_section_score(sec))
return out
def _section_score(sec: np.ndarray) -> float:
"""
Чистый rin/rout. Для дуги лежачего цилиндра (depth видит полкруга)
допускаем score 0.85 только при жёстком circle-fit:
малый residual, почти равные радиусы, покрытие ≥200°, bbox не «палка».
Прямоугольное сечение fit не проходит → остаётся rin/rout < 0.8.
"""
direct = rin_rout(sec)
if direct >= 0.8:
return float(direct)
fit = _fit_circle_arc(sec)
if fit is None:
return float(direct)
return float(max(direct, 0.85))
def _fit_circle_arc(pts: np.ndarray) -> Optional[Tuple[np.ndarray, float]]:
pts = np.asarray(pts, dtype=np.float64).reshape(-1, 2)
if pts.shape[0] < 35:
return None
c0 = pts.mean(axis=0)
x0 = pts - c0
try:
_u, s, _vt = np.linalg.svd(x0, full_matrices=False)
except np.linalg.LinAlgError:
return None
if s.shape[0] < 2 or float(s[0]) < 1e-6:
return None
# сечение не должно быть линией
if float(s[1] / (s[0] + 1e-9)) < 0.45:
return None
x, y = pts[:, 0], pts[:, 1]
A = np.column_stack([2 * x, 2 * y, np.ones_like(x)])
b = x * x + y * y
try:
sol, *_ = np.linalg.lstsq(A, b, rcond=None)
except np.linalg.LinAlgError:
return None
cx_, cy_, c = sol
r2 = c + cx_ * cx_ + cy_ * cy_
if r2 <= 1.0:
return None
r = float(np.sqrt(r2))
rad = np.sqrt((x - cx_) ** 2 + (y - cy_) ** 2)
rel = float(np.sqrt(np.mean((rad - r) ** 2)) / (r + 1e-9))
rad_cv = float(rad.std() / (rad.mean() + 1e-9))
if rel > 0.04 or rad_cv > 0.04:
return None
bw = float(x.max() - x.min())
bh = float(y.max() - y.min())
aspect = max(bw, bh) / max(min(bw, bh), 1e-6)
# у круга/полукруга bbox близок к квадрату; у прямоугольника 2:1 — нет
if aspect > 1.45:
return None
if r < 0.40 * max(bw, bh) or r > 0.70 * max(bw, bh):
return None
ang = np.arctan2(y - cy_, x - cx_)
ang = np.sort(ang)
gaps = np.diff(ang)
gaps = np.append(gaps, ang[0] + 2 * np.pi - ang[-1])
coverage = float(2 * np.pi - gaps.max())
if coverage < np.deg2rad(200.0):
return None
return np.array([cx_, cy_], dtype=np.float32), r
def _inscribed_radius_mm(pts_xy_mm: np.ndarray, grid: int = 192) -> float:
x_min, y_min = pts_xy_mm.min(axis=0)
x_max, y_max = pts_xy_mm.max(axis=0)
span = max(float(x_max - x_min), float(y_max - y_min), 1.0)
pad = 8
inner = grid - 2 * pad
if inner < 16:
return 0.0
scale = inner / span
img = np.zeros((grid, grid), dtype=np.uint8)
pts_px = ((pts_xy_mm - np.array([x_min, y_min], dtype=np.float32)) * scale).astype(np.int32)
pts_px[:, 0] = np.clip(pts_px[:, 0] + pad, 0, grid - 1)
pts_px[:, 1] = np.clip(pts_px[:, 1] + pad, 0, grid - 1)
cv2.fillPoly(img, [pts_px], 255)
if img.max() == 0 or float((img > 0).mean()) > 0.98:
return 0.0
dist = cv2.distanceTransform(img, cv2.DIST_L2, 5)
return float(dist.max()) / scale
def circularity_ratio(pts_xy_mm: np.ndarray) -> float:
return rin_rout(pts_xy_mm)
# совместимость со старыми вызовами
def max_section_circle_ratio(
depth_mm: np.ndarray,
mask: np.ndarray,
top_pts_mm: np.ndarray,
fx: float,
fy: float,
cx: float,
cy: float,
) -> float:
top = rin_rout(top_pts_mm)
cloud = _point_cloud(depth_mm, mask, fx, fy, cx, cy)
sec = robust_section_ratio(cloud) if cloud is not None else 0.0
return float(max(top, sec))
def section_rin_rout(sec: np.ndarray) -> float:
return rin_rout(sec)