|
|
"""
|
|
|
箱子堆侧面竖线检测工具
|
|
|
用途:检测箱子堆侧面是否有箱子倾斜或突出
|
|
|
原理:SobelX梯度 → 逐行扫描 + 连续性跟踪 → 聚类成竖线 → 分析异常
|
|
|
"""
|
|
|
|
|
|
import cv2
|
|
|
import numpy as np
|
|
|
from pathlib import Path
|
|
|
from collections import Counter
|
|
|
|
|
|
|
|
|
# ============================================================
|
|
|
# 参数配置区 — 在这里调整所有控制参数
|
|
|
# ============================================================
|
|
|
|
|
|
class Config:
|
|
|
"""所有可调参数,集中管理"""
|
|
|
|
|
|
# ---- 输入/输出 ----
|
|
|
IMAGE_PATH = r"" # 输入图片路径(必填)
|
|
|
OUTPUT_PATH = None # 输出路径,None=自动生成 _inspected 后缀
|
|
|
SHOW_WINDOW = True # 是否弹窗显示结果
|
|
|
|
|
|
# ---- ROI 裁剪区域(在旋转之后应用)----
|
|
|
# 设为 None 表示不裁剪,使用整张图
|
|
|
ROI_X1 = None # 左边界(px),例如 100
|
|
|
ROI_Y1 = None # 上边界(px),例如 50
|
|
|
ROI_X2 = None # 右边界(px),例如 800
|
|
|
ROI_Y2 = None # 下边界(px),例如 900
|
|
|
|
|
|
# ---- 旋转(在裁剪之前应用)----
|
|
|
ROTATE_DEG = 0.0 # 旋转角度(度),正=逆时针。例如图片歪了3度就填 3.0
|
|
|
|
|
|
# ---- 边缘检测方向 ----
|
|
|
# "dark_to_light" = 从右到左,深→浅(SobelX 负值)
|
|
|
# "light_to_dark" = 从右到左,浅→深(SobelX 正值)
|
|
|
GRADIENT_DIR = "dark_to_light"
|
|
|
|
|
|
# ---- 扫描参数 ----
|
|
|
SCAN_STEP = 20 # 垂直扫描步长(px),每 N px 扫一行
|
|
|
BORDER_MARGIN = 30 # 排除左右边缘各 N px(避免边框噪点)
|
|
|
X_CLUSTER_GAP = 40 # X方向聚类间距(px),同一组内的最大间距
|
|
|
MIN_BRIGHTNESS = 25 # 行平均亮度阈值,低于此值的暗行直接跳过(0-255)
|
|
|
MAX_GROUPS = 1 # 最多保留几个竖线组(通常只关注主边框=1)
|
|
|
|
|
|
# ---- 梯度参数 ----
|
|
|
GAUSSIAN_KERNEL = 5 # 高斯模糊核大小(奇数)
|
|
|
MIN_GRADIENT_FIRST = 10.0 # 第一遍粗定位的最低梯度强度
|
|
|
MIN_GRADIENT_TRACK = 6.0 # 第二遍跟踪的最低梯度强度
|
|
|
TRACK_SMOOTH = 0.7 # 跟踪平滑系数(0-1),越大越稳定但响应慢
|
|
|
|
|
|
# ---- 异常检测阈值 ----
|
|
|
PROTRUDE_PX = 15.0 # 突出异常阈值(px):偏离中线的距离
|
|
|
PROTRUDE_MIN_CONSEC = 3 # 突出:最少连续 N 行都突出才算有效
|
|
|
PROTRUDE_PROXIMITY_PX = 10 # 突出:连续突出的各行 X 间距在此范围内才算同一处突出
|
|
|
|
|
|
TILT_DEG = 3.0 # 局部倾斜异常阈值(度):相邻两行之间的角度变化
|
|
|
TILT_MIN_CONSEC = 3 # 倾倒:最少连续 N 段都超过倾斜阈值才算有效
|
|
|
TILT_SIMILARITY_DEG = 2.0 # 倾倒:连续各段角度最大差异在此范围内才认为"相似"
|
|
|
|
|
|
|
|
|
def _build_gradient_map(gray: np.ndarray, direction: str) -> np.ndarray:
|
|
|
"""
|
|
|
SobelX 水平梯度图。
|
|
|
direction:
|
|
|
"dark_to_light" → 右暗左亮,保留 SobelX 负值
|
|
|
"light_to_dark" → 右亮左暗,保留 SobelX 正值
|
|
|
"""
|
|
|
k = Config.GAUSSIAN_KERNEL
|
|
|
blurred = cv2.GaussianBlur(gray, (k, k), 1.0)
|
|
|
sobel_x = cv2.Sobel(blurred, cv2.CV_32F, dx=1, dy=0, ksize=3)
|
|
|
|
|
|
if direction == "dark_to_light":
|
|
|
grad = np.maximum(-sobel_x, 0) # 右暗左亮
|
|
|
else: # light_to_dark
|
|
|
grad = np.maximum(sobel_x, 0) # 右亮左暗
|
|
|
return grad
|
|
|
|
|
|
|
|
|
def _row_brightness(gray_row: np.ndarray) -> float:
|
|
|
"""返回一行灰度的平均值(0-255)。"""
|
|
|
return float(np.mean(gray_row))
|
|
|
|
|
|
|
|
|
def extract_edges(gray: np.ndarray,
|
|
|
scan_step: int,
|
|
|
border_margin: int,
|
|
|
x_cluster_gap: int,
|
|
|
min_brightness: float,
|
|
|
gradient_dir: str,
|
|
|
max_groups: int = 1):
|
|
|
"""
|
|
|
从灰度图逐行扫描,使用连续性跟踪策略。
|
|
|
|
|
|
新增:跳过太暗的行(min_brightness)。
|
|
|
"""
|
|
|
h, w = gray.shape
|
|
|
grad = _build_gradient_map(gray, gradient_dir)
|
|
|
|
|
|
scan_rows = list(range(0, h, scan_step))
|
|
|
n_scan = len(scan_rows)
|
|
|
|
|
|
# ---- 第一遍:粗略定位主区域中心 X(跳过暗行) ----
|
|
|
rough_points = []
|
|
|
for y in scan_rows:
|
|
|
# 跳过太暗的行
|
|
|
if _row_brightness(gray[y, :]) < min_brightness:
|
|
|
continue
|
|
|
row_grad = grad[y, :]
|
|
|
lo = border_margin
|
|
|
hi = w - border_margin
|
|
|
if hi <= lo:
|
|
|
continue
|
|
|
# 找窗口内最大值位置
|
|
|
best_val = -1
|
|
|
best_x = -1
|
|
|
for i in range(lo, hi):
|
|
|
if row_grad[i] > best_val:
|
|
|
best_val = row_grad[i]
|
|
|
best_x = i
|
|
|
if best_x >= 0 and best_val >= Config.MIN_GRADIENT_FIRST:
|
|
|
rough_points.append(best_x)
|
|
|
|
|
|
if not rough_points:
|
|
|
return [], []
|
|
|
|
|
|
main_center = int(np.median(rough_points))
|
|
|
|
|
|
# ---- 第二遍:连续性跟踪(同样跳过暗行) ----
|
|
|
all_points = []
|
|
|
track_x = main_center
|
|
|
smooth = Config.TRACK_SMOOTH
|
|
|
|
|
|
for idx, y in enumerate(scan_rows):
|
|
|
# 跳过太暗的行
|
|
|
if _row_brightness(gray[y, :]) < min_brightness:
|
|
|
continue
|
|
|
|
|
|
row = grad[y, :]
|
|
|
|
|
|
# 策略A:在跟踪位置附近 ±x_cluster_gap 搜索
|
|
|
search_lo = max(border_margin, track_x - x_cluster_gap)
|
|
|
search_hi = min(w - border_margin, track_x + x_cluster_gap)
|
|
|
|
|
|
best_x = None
|
|
|
best_val = -1
|
|
|
|
|
|
for i in range(search_lo, search_hi):
|
|
|
if row[i] > best_val:
|
|
|
best_val = row[i]
|
|
|
best_x = i
|
|
|
|
|
|
# 窗口内太弱,尝试全局搜索
|
|
|
if best_x is not None and best_val < Config.MIN_GRADIENT_TRACK:
|
|
|
glo_lo = border_margin
|
|
|
glo_hi = w - border_margin
|
|
|
g_best_val = -1
|
|
|
g_best_x = None
|
|
|
for i in range(glo_lo, glo_hi):
|
|
|
if row[i] > g_best_val:
|
|
|
g_best_val = row[i]
|
|
|
g_best_x = i
|
|
|
if g_best_x is not None and g_best_val >= Config.MIN_GRADIENT_TRACK * 0.5:
|
|
|
best_x = g_best_x
|
|
|
best_val = g_best_val
|
|
|
else:
|
|
|
best_x = None
|
|
|
|
|
|
if best_x is not None:
|
|
|
all_points.append({
|
|
|
'x': int(best_x),
|
|
|
'y': y,
|
|
|
'row_idx': idx,
|
|
|
})
|
|
|
track_x = int(track_x * smooth + best_x * (1 - smooth))
|
|
|
|
|
|
if not all_points:
|
|
|
return [], []
|
|
|
|
|
|
# ---- 按 X 聚类 ----
|
|
|
all_points.sort(key=lambda p: p['x'])
|
|
|
clusters = []
|
|
|
cluster = [all_points[0]]
|
|
|
for i in range(1, len(all_points)):
|
|
|
if all_points[i]['x'] - cluster[-1]['x'] <= x_cluster_gap:
|
|
|
cluster.append(all_points[i])
|
|
|
else:
|
|
|
clusters.append(cluster)
|
|
|
cluster = [all_points[i]]
|
|
|
clusters.append(cluster)
|
|
|
|
|
|
# 过滤小簇
|
|
|
min_count = max(3, int(n_scan * 0.08))
|
|
|
clusters = [c for c in clusters if len(c) >= min_count]
|
|
|
|
|
|
# 只保留最显著的组
|
|
|
clusters.sort(key=len, reverse=True)
|
|
|
clusters = clusters[:max_groups]
|
|
|
|
|
|
# ---- 构建结果 ----
|
|
|
segments = []
|
|
|
groups_info = []
|
|
|
|
|
|
for gid, cl in enumerate(clusters):
|
|
|
cl_sorted = sorted(cl, key=lambda p: p['y'])
|
|
|
xs_arr = np.array([p['x'] for p in cl_sorted], dtype=np.float64)
|
|
|
ys_arr = np.array([p['y'] for p in cl_sorted], dtype=np.float64)
|
|
|
|
|
|
if len(xs_arr) >= 3:
|
|
|
coeffs = np.polyfit(ys_arr, xs_arr, 1)
|
|
|
angle_h = np.degrees(np.arctan(coeffs[0]))
|
|
|
else:
|
|
|
angle_h = 0.0
|
|
|
angle_v = round((90.0 - angle_h) % 180, 2)
|
|
|
median_x = int(np.median(xs_arr))
|
|
|
|
|
|
groups_info.append({
|
|
|
'group_id': gid,
|
|
|
'median_x': median_x,
|
|
|
'angle': angle_v,
|
|
|
'count': len(cl_sorted),
|
|
|
'y_min': int(ys_arr.min()) if len(ys_arr) else 0,
|
|
|
'y_max': int(ys_arr.max()) if len(ys_arr) else 0,
|
|
|
})
|
|
|
|
|
|
for p in cl_sorted:
|
|
|
segments.append({
|
|
|
'x': p['x'],
|
|
|
'y': p['y'],
|
|
|
'row_idx': p['row_idx'],
|
|
|
'group_id': gid,
|
|
|
'group_angle': angle_v,
|
|
|
})
|
|
|
|
|
|
segments.sort(key=lambda s: (s['group_id'], s['y']))
|
|
|
return segments, groups_info
|
|
|
|
|
|
|
|
|
# ============================================================
|
|
|
# 图像预处理:旋转 + ROI裁剪
|
|
|
# ============================================================
|
|
|
|
|
|
def preprocess_image(img: np.ndarray,
|
|
|
rotate_deg: float,
|
|
|
roi: tuple | None) -> tuple[np.ndarray, np.ndarray]:
|
|
|
"""
|
|
|
预处理:旋转 → ROI裁剪 → 返回 (处理后彩色图, 处理后灰度图)
|
|
|
|
|
|
roi = (x1, y1, x2, y2) 或 None
|
|
|
"""
|
|
|
result = img.copy()
|
|
|
|
|
|
# ---- 旋转 ----
|
|
|
if abs(rotate_deg) > 0.001:
|
|
|
h, w = result.shape[:2]
|
|
|
center = (w // 2, h // 2)
|
|
|
M = cv2.getRotationMatrix2D(center, rotate_deg, 1.0)
|
|
|
# 计算新图像尺寸以包含全部内容
|
|
|
cos_a = abs(M[0, 0])
|
|
|
sin_a = abs(M[0, 1])
|
|
|
new_w = int(h * sin_a + w * cos_a)
|
|
|
new_h = int(h * cos_a + w * sin_a)
|
|
|
M[0, 2] += (new_w - w) / 2
|
|
|
M[1, 2] += (new_h - h) / 2
|
|
|
result = cv2.warpAffine(result, M, (new_w, new_h),
|
|
|
flags=cv2.INTER_LINEAR,
|
|
|
borderValue=(0, 0, 0))
|
|
|
print(f" [预处理] 旋转 {rotate_deg}deg → {new_w}x{new_h}")
|
|
|
|
|
|
# ---- ROI 裁剪 ----
|
|
|
if roi is not None:
|
|
|
x1, y1, x2, y2 = roi
|
|
|
rh, rw = result.shape[:2]
|
|
|
x1 = max(0, min(x1, rw))
|
|
|
y1 = max(0, min(y1, rh))
|
|
|
x2 = max(0, min(x2, rw))
|
|
|
y2 = max(0, min(y2, rh))
|
|
|
if x2 > x1 and y2 > y1:
|
|
|
result = result[y1:y2, x1:x2]
|
|
|
print(f" [预处理] ROI裁剪 ({x1},{y1})-({x2},{y2}) → {result.shape[1]}x{result.shape[0]}")
|
|
|
|
|
|
gray = cv2.cvtColor(result, cv2.COLOR_BGR2GRAY)
|
|
|
return result, gray
|
|
|
|
|
|
|
|
|
# ============================================================
|
|
|
# 异常分析
|
|
|
# ============================================================
|
|
|
|
|
|
def analyze_segments(segments: list,
|
|
|
groups_info: list,
|
|
|
protrude_px: float,
|
|
|
protrude_min_consec: int,
|
|
|
protrude_proximity_px: float,
|
|
|
tilt_deg: float,
|
|
|
tilt_min_consec: int,
|
|
|
tilt_similarity_deg: float) -> dict:
|
|
|
"""
|
|
|
异常分析(连续段判定):
|
|
|
|
|
|
【突出】同一组内连续 N 行都突出,且相邻突出行 X 间距 < proximity_px → 报异常
|
|
|
【倾倒】同一组内连续 N 段的局部倾斜都 > tilt_deg,且各段角度相近(< similarity_deg) → 报异常
|
|
|
"""
|
|
|
if not segments or not groups_info:
|
|
|
return {'alerts': [], 'stats': {'total_segments': 0, 'x_range': (0,0), 'groups': []}}
|
|
|
|
|
|
all_x = [s['x'] for s in segments]
|
|
|
x_low = float(np.percentile(all_x, 5))
|
|
|
x_high = float(np.percentile(all_x, 95))
|
|
|
stats = {'total_segments': len(segments), 'x_range': (x_low, x_high), 'groups': groups_info}
|
|
|
alerts = []
|
|
|
|
|
|
for ginfo in groups_info:
|
|
|
gid = ginfo['group_id']
|
|
|
median_x = float(ginfo['median_x'])
|
|
|
group_segs = [s for s in segments if s['group_id'] == gid]
|
|
|
if len(group_segs) < 2:
|
|
|
continue
|
|
|
|
|
|
# ============================================
|
|
|
# 1. 突出检测:连续 N 行突出 + 位置相近
|
|
|
# ============================================
|
|
|
# 标记每行是否突出
|
|
|
protruding_indices = [] # 在 group_segs 中的索引
|
|
|
for idx, seg in enumerate(group_segs):
|
|
|
off = abs(seg['x'] - median_x)
|
|
|
if off > protrude_px:
|
|
|
protruding_indices.append(idx)
|
|
|
|
|
|
# 找连续段
|
|
|
if protruding_indices:
|
|
|
runs = []
|
|
|
run_start = 0
|
|
|
for i in range(1, len(protruding_indices)):
|
|
|
gap = protruding_indices[i] - protruding_indices[i - 1]
|
|
|
# 索引连续(gap==1) 且 X 位置相近
|
|
|
prev_seg = group_segs[protruding_indices[i - 1]]
|
|
|
curr_seg = group_segs[protruding_indices[i]]
|
|
|
x_dist = abs(curr_seg['x'] - prev_seg['x'])
|
|
|
if gap <= 2 and x_dist <= protrude_proximity_px:
|
|
|
continue # 同一段
|
|
|
else:
|
|
|
run_len = i - run_start
|
|
|
if run_len >= protrude_min_consec:
|
|
|
runs.append(protruding_indices[run_start:i])
|
|
|
run_start = i
|
|
|
# 最后一段
|
|
|
run_len = len(protruding_indices) - run_start
|
|
|
if run_len >= protrude_min_consec:
|
|
|
runs.append(protruding_indices[run_start:])
|
|
|
|
|
|
for run in runs:
|
|
|
segs_in_run = [group_segs[i] for i in run]
|
|
|
offsets = [round(abs(s['x'] - median_x), 1) for s in segs_in_run]
|
|
|
avg_off = round(np.mean(offsets), 1)
|
|
|
sides = ['right' if s['x'] > median_x else 'left' for s in segs_in_run]
|
|
|
# 多数侧
|
|
|
from collections import Counter
|
|
|
main_side = Counter(sides).most_common(1)[0][0]
|
|
|
|
|
|
alerts.append({
|
|
|
'type': 'protrude',
|
|
|
'group_id': gid,
|
|
|
'side': main_side,
|
|
|
'y_start': segs_in_run[0]['y'],
|
|
|
'y_end': segs_in_run[-1]['y'],
|
|
|
'count': len(segs_in_run),
|
|
|
'avg_offset_px': avg_off,
|
|
|
'segments': segs_in_run,
|
|
|
})
|
|
|
|
|
|
# ============================================
|
|
|
# 2. 倾倒检测:连续 N 段局部倾斜都超标 + 角度相似
|
|
|
# ============================================
|
|
|
local_tilts = [] # [(seg_index, tilt_angle), ...]
|
|
|
for i in range(1, len(group_segs)):
|
|
|
p, c = group_segs[i - 1], group_segs[i]
|
|
|
dy = c['y'] - p['y']
|
|
|
if dy == 0:
|
|
|
continue
|
|
|
lt = abs(np.degrees(np.arctan((c['x'] - p['x']) / dy)))
|
|
|
local_tilts.append((i, round(lt, 2)))
|
|
|
|
|
|
# 找连续段:每段倾斜都超标 + 角度差异在 similarity 内
|
|
|
tilted_indices = [t for t in local_tilts if t[1] > tilt_deg]
|
|
|
|
|
|
if tilted_indices:
|
|
|
runs = []
|
|
|
run_start = 0
|
|
|
for i in range(1, len(tilted_indices)):
|
|
|
gap = tilted_indices[i][0] - tilted_indices[i - 1][0]
|
|
|
angle_diff = abs(tilted_indices[i][1] - tilted_indices[i - 1][1])
|
|
|
if gap <= 2 and angle_diff <= tilt_similarity_deg:
|
|
|
continue
|
|
|
else:
|
|
|
run_len = i - run_start
|
|
|
if run_len >= tilt_min_consec:
|
|
|
runs.append(tilted_indices[run_start:i])
|
|
|
run_start = i
|
|
|
run_len = len(tilted_indices) - run_start
|
|
|
if run_len >= tilt_min_consec:
|
|
|
runs.append(tilted_indices[run_start:])
|
|
|
|
|
|
for run in runs:
|
|
|
angles = [t[1] for t in run]
|
|
|
avg_angle = round(np.mean(angles), 2)
|
|
|
# 涉及的 segments
|
|
|
start_idx = run[0][0] - 1 # 局部倾斜的起点段索引
|
|
|
end_idx = run[-1][0]
|
|
|
involved = group_segs[start_idx:end_idx + 1]
|
|
|
|
|
|
alerts.append({
|
|
|
'type': 'tilt',
|
|
|
'group_id': gid,
|
|
|
'y_start': involved[0]['y'],
|
|
|
'y_end': involved[-1]['y'],
|
|
|
'count': len(run),
|
|
|
'avg_tilt_deg': avg_angle,
|
|
|
'max_tilt_deg': round(max(angles), 2),
|
|
|
'segments': involved,
|
|
|
})
|
|
|
|
|
|
return {'alerts': alerts, 'stats': stats}
|
|
|
|
|
|
|
|
|
# ============================================================
|
|
|
# 可视化 — 只画竖线,绿正常红异常
|
|
|
# ============================================================
|
|
|
|
|
|
def draw_results(img: np.ndarray, segments: list,
|
|
|
groups_info: list, alerts: list) -> np.ndarray:
|
|
|
"""
|
|
|
画竖线:绿色=正常,红色=异常。
|
|
|
异常按连续段标记(alert 里的 'segments' 字段给出具体行)。
|
|
|
"""
|
|
|
result = img.copy()
|
|
|
h, _ = result.shape[:2]
|
|
|
|
|
|
# 收集所有异常行(x, y)
|
|
|
alert_rows = set()
|
|
|
for a in alerts:
|
|
|
if 'segments' in a:
|
|
|
for s in a['segments']:
|
|
|
alert_rows.add((int(s['x']), int(s['y'])))
|
|
|
|
|
|
for ginfo in groups_info:
|
|
|
gid = ginfo['group_id']
|
|
|
for s in segments:
|
|
|
if s['group_id'] != gid:
|
|
|
continue
|
|
|
sx, sy = s['x'], s['y']
|
|
|
bad = (sx, sy) in alert_rows
|
|
|
cv2.line(result, (sx, max(0, sy - 10)), (sx, min(h, sy + 10)),
|
|
|
(0, 0, 255) if bad else (0, 220, 0), 3 if bad else 2)
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
# ============================================================
|
|
|
# 主流程
|
|
|
# ============================================================
|
|
|
|
|
|
def inspect(image_path: str,
|
|
|
output_path: str = None,
|
|
|
show: bool = False,
|
|
|
rotate_deg: float = 0.0,
|
|
|
roi: tuple | None = None,
|
|
|
gradient_dir: str = "dark_to_light",
|
|
|
scan_step: int = 20,
|
|
|
border_margin: int = 30,
|
|
|
x_cluster_gap: int = 40,
|
|
|
min_brightness: float = 25.0,
|
|
|
max_groups: int = 1,
|
|
|
protrude_px: float = 15.0,
|
|
|
protrude_min_consec: int = 3,
|
|
|
protrude_proximity_px: float = 10.0,
|
|
|
tilt_deg: float = 3.0,
|
|
|
tilt_min_consec: int = 3,
|
|
|
tilt_similarity_deg: float = 2.0) -> dict:
|
|
|
|
|
|
img = cv2.imread(image_path)
|
|
|
if img is None:
|
|
|
raise FileNotFoundError(f"无法读取图片: {image_path}")
|
|
|
print(f"[输入] {image_path} 原始尺寸: {img.shape[1]}x{img.shape[0]}")
|
|
|
|
|
|
# ---- 预处理:旋转 + ROI裁剪 ----
|
|
|
processed_img, gray = preprocess_image(img, rotate_deg, roi)
|
|
|
|
|
|
# ---- 边缘提取 ----
|
|
|
segments, groups_info = extract_edges(
|
|
|
gray,
|
|
|
scan_step=scan_step,
|
|
|
border_margin=border_margin,
|
|
|
x_cluster_gap=x_cluster_gap,
|
|
|
min_brightness=min_brightness,
|
|
|
gradient_dir=gradient_dir,
|
|
|
max_groups=max_groups,
|
|
|
)
|
|
|
|
|
|
# ---- 异常分析 ----
|
|
|
result = analyze_segments(
|
|
|
segments, groups_info,
|
|
|
protrude_px=protrude_px,
|
|
|
protrude_min_consec=protrude_min_consec,
|
|
|
protrude_proximity_px=protrude_proximity_px,
|
|
|
tilt_deg=tilt_deg,
|
|
|
tilt_min_consec=tilt_min_consec,
|
|
|
tilt_similarity_deg=tilt_similarity_deg,
|
|
|
)
|
|
|
|
|
|
# ---- 可视化(在预处理后的图上画) ----
|
|
|
annotated = draw_results(processed_img, segments, groups_info, result['alerts'])
|
|
|
|
|
|
# ---- 保存 ----
|
|
|
if output_path is None:
|
|
|
p = Path(image_path)
|
|
|
output_path = str(p.parent / f"{p.stem}_inspected{p.suffix}")
|
|
|
cv2.imwrite(output_path, annotated)
|
|
|
print(f"[OK] 结果已保存: {output_path}")
|
|
|
|
|
|
if show:
|
|
|
cv2.imshow("Box Edge Inspector", annotated)
|
|
|
cv2.waitKey(0)
|
|
|
cv2.destroyAllWindows()
|
|
|
|
|
|
# ---- 报告 ----
|
|
|
dir_label = "深→浅" if gradient_dir == "dark_to_light" else "浅→深"
|
|
|
print(f"\n{'='*50}")
|
|
|
print(f"检测完成: {image_path}")
|
|
|
print(f" 方向: {dir_label} | 旋转: {rotate_deg}deg")
|
|
|
if roi:
|
|
|
print(f" ROI: {roi}")
|
|
|
print(f" 扫描步长: {scan_step}px | 最小亮度: {min_brightness}")
|
|
|
print(f" 竖线段数: {result['stats']['total_segments']}")
|
|
|
print(f" 轮廓组数: {len(result['stats']['groups'])}")
|
|
|
print(f" X范围: [{result['stats']['x_range'][0]:.0f}, {result['stats']['x_range'][1]:.0f}]")
|
|
|
print(f" 突出阈值: {protrude_px}px 连续{protrude_min_consec}行 | 倾倒阈值: {tilt_deg}deg 连续{tilt_min_consec}段")
|
|
|
|
|
|
if result['alerts']:
|
|
|
print(f" ! 发现{len(result['alerts'])}处异常:")
|
|
|
for i, a in enumerate(result['alerts'], 1):
|
|
|
t = a['type']
|
|
|
if t == 'tilt':
|
|
|
print(f" {i}. [倾倒] Y={a['y_start']}~{a['y_end']} "
|
|
|
f"{a['count']}段 平均{a['avg_tilt_deg']}deg 最大{a['max_tilt_deg']}deg")
|
|
|
elif t == 'protrude':
|
|
|
print(f" {i}. [突出] Y={a['y_start']}~{a['y_end']} "
|
|
|
f"向{a['side']}{a['avg_offset_px']}px {a['count']}行")
|
|
|
else:
|
|
|
print(f" OK 无异常")
|
|
|
print('='*50)
|
|
|
|
|
|
return {
|
|
|
'ok': len(result['alerts'])==0,
|
|
|
'alerts': result['alerts'],
|
|
|
'stats': result['stats'],
|
|
|
'output_path': output_path,
|
|
|
}
|
|
|
|
|
|
|
|
|
# ============================================================
|
|
|
if __name__ == '__main__':
|
|
|
|
|
|
# ─── 所有参数在这里改 ──────────────────────────────
|
|
|
C = Config
|
|
|
|
|
|
inspect(
|
|
|
image_path=C.IMAGE_PATH,
|
|
|
output_path=C.OUTPUT_PATH,
|
|
|
show=C.SHOW_WINDOW,
|
|
|
|
|
|
# 几何变换
|
|
|
rotate_deg=C.ROTATE_DEG,
|
|
|
roi=(C.ROI_X1, C.ROI_Y1, C.ROI_X2, C.ROI_Y2) if C.ROI_X1 is not None else None,
|
|
|
|
|
|
# 检测方向
|
|
|
gradient_dir=C.GRADIENT_DIR, # "dark_to_light" 或 "light_to_dark"
|
|
|
|
|
|
# 扫描参数
|
|
|
scan_step=C.SCAN_STEP, # 每 N px 扫一行
|
|
|
border_margin=C.BORDER_MARGIN, # 排除边缘 N px
|
|
|
x_cluster_gap=C.X_CLUSTER_GAP, # 组内最大间距
|
|
|
min_brightness=C.MIN_BRIGHTNESS, # 暗行阈值,低于此跳过
|
|
|
max_groups=C.MAX_GROUPS, # 保留几个竖线组
|
|
|
|
|
|
# 异常阈值
|
|
|
protrude_px=C.PROTRUDE_PX,
|
|
|
protrude_min_consec=C.PROTRUDE_MIN_CONSEC,
|
|
|
protrude_proximity_px=C.PROTRUDE_PROXIMITY_PX,
|
|
|
tilt_deg=C.TILT_DEG,
|
|
|
tilt_min_consec=C.TILT_MIN_CONSEC,
|
|
|
tilt_similarity_deg=C.TILT_SIMILARITY_DEG,
|
|
|
)
|