diff --git a/modules/common/target/common-1.0.0.jar b/modules/common/target/common-1.0.0.jar index 56ce2d6..b19e421 100644 Binary files a/modules/common/target/common-1.0.0.jar and b/modules/common/target/common-1.0.0.jar differ diff --git a/modules/filter/target/filter-1.0.0.jar b/modules/filter/target/filter-1.0.0.jar index d2e9cab..61a119c 100644 Binary files a/modules/filter/target/filter-1.0.0.jar and b/modules/filter/target/filter-1.0.0.jar differ diff --git a/s7DB.txt b/s7DB.txt index e4290de..9f3f6b0 100644 --- a/s7DB.txt +++ b/s7DB.txt @@ -2,37 +2,37 @@ #其中,C1、C2、C3是int类型,ET、ED是布尔类型,C1直接是赋值初始位置,ED底部默认.0,ET顶部默认.1 #格式是{货架号}-{触发任务}[-输出]:{初始值} 001-C1:0 -001-C2:4 -001-C3:8 +#001-C2:4 +#001-C3:8 001-C1-out:48 -001-C2-out:52 -001-C3-out:56 -001-ET-out:60 -001-ED-out:60 +#001-C2-out:52 +#001-C3-out:56 +#001-ET-out:60 +#001-ED-out:60 002-C1:12 -002-C2:16 -002-C3:20 +#002-C2:16 +#002-C3:20 002-C1-out:62 -002-C2-out:66 -002-C3-out:70 -002-ET-out:74 -002-ED-out:74 +#002-C2-out:66 +#002-C3-out:70 +#002-ET-out:74 +#002-ED-out:74 003-C1:24 -003-C2:28 -003-C3:32 +#003-C2:8 +#003-C3:32 003-C1-out:76 -003-C2-out:80 -003-C3-out:84 -003-ET-out:88 -003-ED-out:88 +#003-C2-out:80 +#003-C3-out:84 +#003-ET-out:88 +#003-ED-out:88 004-C1:36 -004-C2:40 -004-C3:44 -004-C1-out:90 -004-C2-out:94 -004-C3-out:98 -004-ET-out:102 -004-ED-out:102 \ No newline at end of file +#004-C2:40 +#004-C3:44 +#004-C1-out:90 +#004-C2-out:94 +#004-C3-out:98 +#004-ET-out:102 +#004-ED-out:102 \ No newline at end of file diff --git a/scripts/box_edge_inspector.py b/scripts/box_edge_inspector.py new file mode 100644 index 0000000..0e29439 --- /dev/null +++ b/scripts/box_edge_inspector.py @@ -0,0 +1,594 @@ +""" +箱子堆侧面竖线检测工具 +用途:检测箱子堆侧面是否有箱子倾斜或突出 +原理: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, + ) diff --git a/scripts/generate_box_dataset.py b/scripts/generate_box_dataset.py new file mode 100644 index 0000000..279a438 --- /dev/null +++ b/scripts/generate_box_dataset.py @@ -0,0 +1,426 @@ +""" +密集堆叠箱子 YOLO 实例分割数据集生成器 v3 +============================================== +整体流程: + 1. 在临时画布上【正交拼接】所有箱子 → 无间隙、无黑色底色 + 2. 对整张拼图做【一次全局透视变换】→ 统一的俯视畸变 + 3. 贴到背景图【中间 60% 区域】 + 4. labels: 整个堆垛的 外轮廓多边形(所有箱子角点的凸包) + +修复: + - 拼图画布用第一个箱子纹理铺底,零间隙 → 无黑色 + - label 为整体堆垛多边形(如 3 箱合并为一个 6+ 点多边形) +""" + +import cv2 +import numpy as np +from pathlib import Path +import random +import os +from concurrent.futures import ThreadPoolExecutor, as_completed + + +# ============================================================ +# 配置 +# ============================================================ + +class Config: + # ---- 必填 ---- + BOX_DIR = r"" # 箱子正面图文件夹 + BG_DIR = r"" # 背景环境图文件夹 + + # ---- 输出 ---- + OUTPUT_DIR = r"./box_dataset" + NUM_IMAGES = 500 + TRAIN_RATIO = 0.8 + IMG_WIDTH = 1280 + IMG_HEIGHT = 960 + + # ---- 画面占比 ---- + STACK_CENTER_RATIO = 0.60 # 堆垛占画面中间的比例(宽高各60%) + STACK_Y_OFFSET = 0.08 # 堆垛整体下移 + + # ---- 堆垛结构 ---- + MIN_COLS = 2 + MAX_COLS = 5 + MIN_BASE_ROWS = 1 + MAX_BASE_ROWS = 4 + MAX_STACK_HEIGHT = 4 + + # ---- 箱子尺寸(正交画布上) ---- + BOX_W_MIN = 80 + BOX_W_MAX = 180 + BOX_H_MIN = 100 + BOX_H_MAX = 240 + + # ---- 箱子间距(设为0=零间隙,无黑色缝隙) ---- + GAP_X = 0 + GAP_Y = 0 + + # ---- 全局透视 (上边/下边) ---- + PERSPECTIVE_TOP_RATIO = 1.05 + PERSPECTIVE_VAR = 0.02 + + # ---- 箱子外观 ---- + BRIGHTNESS_RANGE = (0.80, 1.25) + SATURATION_RANGE = (0.75, 1.20) + HUE_SHIFT_RANGE = (-12, 12) + + # ---- 背景 ---- + BG_BLUR_RANGE = (0, 3) + + # ---- 多线程 ---- + WORKERS = 4 + + +# ============================================================ +# 工具 +# ============================================================ + +def load_images(directory: str) -> list: + exts = ('.jpg', '.jpeg', '.png', '.bmp', '.webp') + imgs = [] + p = Path(directory) + if not p.exists(): + return imgs + for f in sorted(p.iterdir()): + if f.suffix.lower() in exts: + img = cv2.imread(str(f)) + if img is not None: + imgs.append(img) + return imgs + + +def adjust_color(img, brightness=1.0, saturation=1.0, hue_shift=0): + hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV).astype(np.float32) + hsv[:, :, 0] = (hsv[:, :, 0] + hue_shift) % 180 + hsv[:, :, 1] = np.clip(hsv[:, :, 1] * saturation, 0, 255) + hsv[:, :, 2] = np.clip(hsv[:, :, 2] * brightness, 0, 255) + return cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2BGR) + + +# ============================================================ +# 堆垛布局 +# ============================================================ + +def build_stack_layout(cfg): + """ + 正交坐标系中构建多层堆垛。 + 返回: boxes, comp_w, comp_h + """ + n_cols = random.randint(cfg.MIN_COLS, cfg.MAX_COLS) + n_base_rows = random.randint(cfg.MIN_BASE_ROWS, cfg.MAX_BASE_ROWS) + + col_widths = [random.randint(cfg.BOX_W_MIN, cfg.BOX_W_MAX) + for _ in range(n_cols)] + row_heights = [random.randint(cfg.BOX_H_MIN, cfg.BOX_H_MAX) + for _ in range(n_base_rows)] + + # 零间距计算坐标 + col_x = [0] + for w in col_widths: + col_x.append(col_x[-1] + w) + row_y = [0] + for h in row_heights: + row_y.append(row_y[-1] + h) + + boxes = [] + for r in range(n_base_rows): + for c in range(n_cols): + boxes.append({ + 'row': r, 'col': c, 'layer': 0, + 'x1': col_x[c], + 'y1': row_y[r], + 'x2': col_x[c + 1], + 'y2': row_y[r + 1], + }) + + # 上层随机叠高 + for layer in range(1, cfg.MAX_STACK_HEIGHT): + for r in range(n_base_rows): + for c in range(n_cols): + if random.random() < 0.5: + continue + below = [b for b in boxes + if b['row'] == r and b['col'] == c + and b['layer'] == layer - 1] + if not below: + continue + base = below[0] + shrink = random.uniform(0.82, 0.96) + bw, bh = base['x2'] - base['x1'], base['y2'] - base['y1'] + nw = max(20, int(bw * shrink)) + nh = max(20, int(bh * shrink)) + nx1 = base['x1'] + (bw - nw) // 2 + ny1 = base['y1'] - nh + boxes.append({ + 'row': r, 'col': c, 'layer': layer, + 'x1': nx1, 'y1': ny1, + 'x2': nx1 + nw, 'y2': ny1 + nh, + }) + + # 获取画布范围 + xs = [b['x1'] for b in boxes] + [b['x2'] for b in boxes] + ys = [b['y1'] for b in boxes] + [b['y2'] for b in boxes] + comp_w = int(max(xs) - min(xs) + 2) + comp_h = int(max(ys) - min(ys) + 2) + dx = min(xs) - 1 + dy = min(ys) - 1 + + for b in boxes: + b['x1'] -= dx + b['x2'] -= dx + b['y1'] -= dy + b['y2'] -= dy + + return boxes, comp_w, comp_h + + +# ============================================================ +# 拼图画布(无黑色底色) +# ============================================================ + +def build_composition(boxes_ortho, comp_w, comp_h, box_imgs, cfg): + """ + 用第一个箱子纹理铺满整张画布,再在上面画各个箱子。 + 全程零间隙,无黑色出现。 + """ + # 拿一个箱子纹理铺满画布(消除黑色底色) + base_tex = random.choice(box_imgs).copy() + base_tex = adjust_color( + base_tex, + brightness=random.uniform(*cfg.BRIGHTNESS_RANGE), + saturation=random.uniform(*cfg.SATURATION_RANGE), + hue_shift=int(random.uniform(*cfg.HUE_SHIFT_RANGE)), + ) + comp = cv2.resize(base_tex, (comp_w, comp_h), interpolation=cv2.INTER_AREA) + + # 按层排序(底层先画) + boxes_sorted = sorted(boxes_ortho, key=lambda b: (b['layer'], b['y1'])) + + for bi in boxes_sorted: + x1 = int(max(0, round(bi['x1']))) + y1 = int(max(0, round(bi['y1']))) + x2 = int(min(comp_w, round(bi['x2']))) + y2 = int(min(comp_h, round(bi['y2']))) + bw, bh = x2 - x1, y2 - y1 + if bw < 3 or bh < 3: + continue + + tex = random.choice(box_imgs).copy() + tex = adjust_color( + tex, + brightness=random.uniform(*cfg.BRIGHTNESS_RANGE), + saturation=random.uniform(*cfg.SATURATION_RANGE), + hue_shift=int(random.uniform(*cfg.HUE_SHIFT_RANGE)), + ) + tex = cv2.resize(tex, (bw, bh), interpolation=cv2.INTER_AREA) + comp[y1:y2, x1:x2] = tex + + # 箱间细微阴影(只画底层底部) + for bi in boxes_ortho: + if bi['layer'] != 0: + continue + x1 = int(max(0, round(bi['x1']))) + x2 = int(min(comp_w, round(bi['x2']))) + y2 = int(min(comp_h, round(bi['y2']))) + y1 = int(max(0, round(bi['y1']))) + if y2 - y1 < 20: + continue + shadow_h = max(1, (y2 - y1) // 30) + for si in range(shadow_h): + sy = y2 - shadow_h + si + a = 0.20 * (1 - si / shadow_h) + row = comp[sy, x1:x2].astype(np.float32) * (1 - a) + comp[sy, x1:x2] = np.clip(row, 0, 255).astype(np.uint8) + + return comp + + +# ============================================================ +# 单张生成 +# ============================================================ + +def generate_one(idx, box_imgs, bg_imgs, cfg, out_img_dir, out_lbl_dir): + img_w, img_h = cfg.IMG_WIDTH, cfg.IMG_HEIGHT + + # ---- 1. 正交布局 ---- + boxes_ortho, comp_w, comp_h = build_stack_layout(cfg) + if not boxes_ortho: + return f"box_{idx:05d}.jpg", 0 + + # ---- 2. 拼图(零黑色) ---- + comp = build_composition(boxes_ortho, comp_w, comp_h, box_imgs, cfg) + + # ---- 3. 全局透视 warp ---- + top_ratio = cfg.PERSPECTIVE_TOP_RATIO + random.uniform( + -cfg.PERSPECTIVE_VAR, cfg.PERSPECTIVE_VAR) + + stack_w = int(img_w * cfg.STACK_CENTER_RATIO) + stack_h = int(img_h * cfg.STACK_CENTER_RATIO) + mid_x = img_w / 2.0 + offset_y = int(img_h * cfg.STACK_Y_OFFSET) + top_y = int(img_h / 2 - stack_h / 2 + offset_y) + bot_y = top_y + stack_h + + src_corners = np.float32([[0, 0], [comp_w, 0], + [comp_w, comp_h], [0, comp_h]]) + dst_corners = np.float32([ + [mid_x - (stack_w / 2) * top_ratio, top_y], + [mid_x + (stack_w / 2) * top_ratio, top_y], + [mid_x + stack_w / 2, bot_y], + [mid_x - stack_w / 2, bot_y], + ]) + + M = cv2.getPerspectiveTransform(src_corners, dst_corners) + + warped = cv2.warpPerspective( + comp, M, (img_w, img_h), + borderMode=cv2.BORDER_CONSTANT, + borderValue=(0, 0, 0), + ) + mask = cv2.warpPerspective( + np.ones((comp_h, comp_w), dtype=np.uint8) * 255, + M, (img_w, img_h), + borderMode=cv2.BORDER_CONSTANT, + borderValue=0, + ) + + # ---- 4. 贴到背景 ---- + bg = random.choice(bg_imgs).copy() + bg = cv2.resize(bg, (img_w, img_h)) + bk = random.randint(*cfg.BG_BLUR_RANGE) + if bk >= 3: + if bk % 2 == 0: + bk += 1 + bg = cv2.GaussianBlur(bg, (bk, bk), 0) + + canvas = bg.copy() + valid = mask > 0 + for ch in range(3): + canvas[:, :, ch] = np.where(valid, warped[:, :, ch], + canvas[:, :, ch]) + + # ---- 5. 标签:整体轮廓多边形 ---- + # 收集所有箱子角点在最终图像中的坐标 + all_corners_final = [] + for bi in boxes_ortho: + corners_comp = np.float32([ + [bi['x1'], bi['y1']], + [bi['x2'], bi['y1']], + [bi['x2'], bi['y2']], + [bi['x1'], bi['y2']], + ]) + ones = np.ones((4, 1), dtype=np.float32) + pts_h = np.hstack([corners_comp, ones]) + pts_f = (M @ pts_h.T).T + pts_f[:, 0] /= pts_f[:, 2] + pts_f[:, 1] /= pts_f[:, 2] + all_corners_final.append(pts_f[:, :2]) + + all_pts = np.vstack(all_corners_final) # (N, 2) + + # 凸包 → 整个堆垛的外轮廓 + hull = cv2.convexHull(all_pts.astype(np.float32)) + hull = hull.squeeze(1) # (N, 2) + + # 简化(每隔1个点取一个,避免过多点) + if len(hull) > 12: + keep = np.arange(0, len(hull), 2) + hull = hull[keep] + + norm_parts = [] + for pt in hull: + norm_parts.append(f"{pt[0] / img_w:.6f}") + norm_parts.append(f"{pt[1] / img_h:.6f}") + label_line = "0 " + " ".join(norm_parts) + + # ---- 保存 ---- + fname = f"box_{idx:05d}.jpg" + cv2.imwrite(os.path.join(out_img_dir, fname), canvas, + [cv2.IMWRITE_JPEG_QUALITY, 95]) + + lbl_path = os.path.join(out_lbl_dir, f"box_{idx:05d}.txt") + with open(lbl_path, 'w') as f: + f.write(label_line + "\n") + + return fname, 1 # 每张图1个标签(整体) + + +# ============================================================ +# 主函数 +# ============================================================ + +def main(): + c = Config + + if not c.BOX_DIR or not Path(c.BOX_DIR).exists(): + raise FileNotFoundError("请设置 BOX_DIR") + if not c.BG_DIR or not Path(c.BG_DIR).exists(): + raise FileNotFoundError("请设置 BG_DIR") + + box_imgs = load_images(c.BOX_DIR) + bg_imgs = load_images(c.BG_DIR) + + if len(box_imgs) < 2: + raise ValueError(f"箱子纹理太少({len(box_imgs)}),至少2张") + if len(bg_imgs) < 1: + raise ValueError("背景图为空") + + print("=" * 55) + print(f"[纹理] 箱子: {len(box_imgs)}张 | 背景: {len(bg_imgs)}张") + print(f"[输出] {c.IMG_WIDTH}x{c.IMG_HEIGHT} | {c.NUM_IMAGES}张") + print(f"[布局] {c.MIN_COLS}-{c.MAX_COLS}列 x " + f"{c.MIN_BASE_ROWS}-{c.MAX_BASE_ROWS}行 | " + f"最高{c.MAX_STACK_HEIGHT}层") + print(f"[标签] 整体堆垛轮廓多边形(凸包)") + print("=" * 55) + + out = Path(c.OUTPUT_DIR) + for split in ['train', 'val']: + (out / 'images' / split).mkdir(parents=True, exist_ok=True) + (out / 'labels' / split).mkdir(parents=True, exist_ok=True) + + n_train = int(c.NUM_IMAGES * c.TRAIN_RATIO) + n_val = c.NUM_IMAGES - n_train + + print(f"\n[生成] train={n_train} val={n_val}") + + jobs = [] + for i in range(c.NUM_IMAGES): + split = 'train' if i < n_train else 'val' + jobs.append((i, str(out / 'images' / split), + str(out / 'labels' / split))) + + done = 0 + with ThreadPoolExecutor(max_workers=c.WORKERS) as pool: + futures = { + pool.submit(generate_one, idx, box_imgs, bg_imgs, c, idir, ldir): idx + for idx, idir, ldir in jobs + } + for fut in as_completed(futures): + fname, _ = fut.result() + done += 1 + if done % 50 == 0 or done == c.NUM_IMAGES: + print(f" [{done}/{c.NUM_IMAGES}] {fname}") + + yaml_text = f"""# YOLO Instance Segmentation - Box Stack +path: {Path(c.OUTPUT_DIR).absolute()} +train: images/train +val: images/val +nc: 1 +names: + 0: box_stack +""" + with open(out / 'data.yaml', 'w') as f: + f.write(yaml_text) + + print(f"\n{'=' * 55}") + print(f"完成! train={n_train} val={n_val}") + print(f"每图1个整体堆垛标签") + print(f"输出: {out.absolute()}") + print("=" * 55) + + +if __name__ == '__main__': + main() diff --git a/web/src/main/java/com/zhehekeji/web/Application.java b/web/src/main/java/com/zhehekeji/web/Application.java index fd31041..0904189 100644 --- a/web/src/main/java/com/zhehekeji/web/Application.java +++ b/web/src/main/java/com/zhehekeji/web/Application.java @@ -1,5 +1,6 @@ package com.zhehekeji.web; +import io.netty.util.ResourceLeakDetector; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -9,6 +10,11 @@ import org.springframework.cache.annotation.EnableCaching; @MapperScan("com.zhehekeji.**.mapper.**") public class Application { public static void main(String[] args) { + // Netty 泄漏检测设置为 SIMPLE:仅采样检测,大幅降低检测开销, + // 避免泄漏检测本身影响其他业务的正常运行 + // 级别:DISABLED(关) < SIMPLE(采样,推荐) < ADVANCED < PARANOID(全量,高开销) + ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.SIMPLE); + SpringApplication springApplication = new SpringApplication(Application.class); springApplication.run(args); } diff --git a/web/src/main/java/com/zhehekeji/web/mapper/OrderMapper.java b/web/src/main/java/com/zhehekeji/web/mapper/OrderMapper.java index 40ddacc..2913b20 100644 --- a/web/src/main/java/com/zhehekeji/web/mapper/OrderMapper.java +++ b/web/src/main/java/com/zhehekeji/web/mapper/OrderMapper.java @@ -16,6 +16,8 @@ public interface OrderMapper extends BaseMapper { @Select("select * from `order` where street_id = #{streetId} order by start_time desc limit 1") Order getOneByStreetId(Integer streetId); + @Select("select * from `order` where street_id = #{streetId} and order_num= #{orderNumber} order by start_time desc limit 1") + Order getOneByStreetId(Integer streetId,String orderNumber); List list(@Param("req")OrderSearch orderSearch); } diff --git a/web/src/main/java/com/zhehekeji/web/service/EmptyCheckService.java b/web/src/main/java/com/zhehekeji/web/service/EmptyCheckService.java index a9e205c..dc90364 100644 --- a/web/src/main/java/com/zhehekeji/web/service/EmptyCheckService.java +++ b/web/src/main/java/com/zhehekeji/web/service/EmptyCheckService.java @@ -14,6 +14,7 @@ import com.zhehekeji.web.pojo.stock.CheckStatus; import com.zhehekeji.web.pojo.stock.RowColumnStatus; import com.zhehekeji.web.service.client.Transmission; import com.zhehekeji.web.service.cron.PLCConnectionExample; +import com.zhehekeji.web.service.cron.ZlmService; import com.zhehekeji.web.service.ksec.KsecDataInfo; import com.zhehekeji.web.service.ksec.KsecInfo; import lombok.extern.slf4j.Slf4j; @@ -42,6 +43,12 @@ public class EmptyCheckService { @Resource private ConfigProperties configProperties; + @Resource + private ZlmService zlmService; + + @Resource + CameraService cameraService; + @Resource PLCConnectionExample plcConnectionExample; /** @@ -51,24 +58,36 @@ public class EmptyCheckService { */ public Order visualJudgment(Transmission transmission,String code,int i){ String key = transmission.getSRMNumber() + "-"; + + Street street = getStreet(transmission); + log.info("transmission,{} key :{} value :{}",transmission.toString(),key + "-out"); if("TP".equals(code)){ key+="C3"; }else if("BP".equals(code)){ key+="C2"; } - Street street = getStreet(transmission); - log.info("transmission,{}",transmission.toString()); - plcConnectionExample. writePlcDataTaskId(key + "-out"); +// plcConnectionExample. writePlcDataTaskId(key + "-out"); if(street!= null){ - Order order = orderMapper.getOneByStreetId(street.getId()); + + Order order = orderMapper.getOneByStreetId(street.getId(),transmission.getSRMNumber()+"_"+transmission.getTaskId()); + if("BP".equals(code)){ + if (order == null) { + log.error("未找到任务 "+transmission.getSRMNumber()+"_"+transmission.getTaskId()); + return null; + } + order.setEndTime(LocalDateTime.now()); + + String path = zlmService.stopRecord(cameraService.getById(street.getCamera1Id())); + order.setVideoPath1(path); + } transmission.setUrl(transmission.getUrl().replace("E:","").replace("D:","")); transmission.setUrl("http://"+street.getPlcIp()+":9007/api/pic"+transmission.getUrl()); - order.setEndTime(LocalDateTime.now()); + order.setPicPaths(order.getPicPaths()!=null ? order.getPicPaths() + "," +transmission.getUrl(): transmission.getUrl()); - if (transmission.getNgBoolean()){ - order.setStatus(1); - plcConnectionService.writePlcDataStatusErr(street.getPlcId(),i); - } +// if (transmission.getNgBoolean()){ +// order.setStatus(1); +// plcConnectionService.writePlcDataStatusErr(street.getPlcId(),i); +// } orderMapper.updateById(order); return order; @@ -78,16 +97,7 @@ public class EmptyCheckService { } @Resource PLCConnectionExample plcConnectionService; - public Order visualJudgmentStatus(Transmission transmission,int i){ - Street street = streetService.getStreetByPlcId(transmission.getSRMNumber()); - if(street!= null){ - Order order = orderMapper.getOneByStreetId(street.getId()); - order.setStatus(1); - orderMapper.updateById(order); - plcConnectionService.writePlcDataStatusErr(street.getPlcId(),i); - } - return null; - } + public KsecInfo getKSECWriteByOrder(Order order,Street street){ KsecDataInfo ksecDataInfo = new KsecDataInfo(); diff --git a/web/src/main/java/com/zhehekeji/web/service/client/Decoder.java b/web/src/main/java/com/zhehekeji/web/service/client/Decoder.java index b281c60..a1f667c 100644 --- a/web/src/main/java/com/zhehekeji/web/service/client/Decoder.java +++ b/web/src/main/java/com/zhehekeji/web/service/client/Decoder.java @@ -33,7 +33,15 @@ public class Decoder extends DelimiterBasedFrameDecoder { private static String EMPTY_CLIENT = "EMPTY"; private static String END_STRING = "$"; - private static ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(5,15,30, TimeUnit.MILLISECONDS,new ArrayBlockingQueue<>(20000)); + /** + * 隔离的线程池:使用 CallerRunsPolicy 反压策略, + * 队列满时在 Netty I/O 线程中直接执行,防止无限堆积 ByteBuf 撑爆内存 + */ + private static ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor( + 5, 15, 30, TimeUnit.SECONDS, + new ArrayBlockingQueue<>(2000), // 队列从 20000 降到 2000,限制最大堆积 + new ThreadPoolExecutor.CallerRunsPolicy() + ); private EmptyCheckService emptyCheckService; @@ -56,10 +64,27 @@ public class Decoder extends DelimiterBasedFrameDecoder { return null; } ClientRunnable clientRunnable = new ClientRunnable(in,ctx,plcService,emptyCheckService); - threadPoolExecutor.execute(clientRunnable); + try { + threadPoolExecutor.execute(clientRunnable); + } catch (Exception e) { + log.error("Decoder thread pool reject, release ByteBuf", e); + in.release(); // 线程池任务被拒绝时必须手动释放 ByteBuf + } return null; } + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + log.debug("Decoder channelInactive, release accumulated buffers"); + super.channelInactive(ctx); + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { + log.error("Decoder exception, closing channel", cause); + ctx.close(); + } + public static class ClientRunnable implements Runnable { private ByteBuf in; @@ -79,35 +104,36 @@ public class Decoder extends DelimiterBasedFrameDecoder { @Override public void run() { - String body = in.toString(Charset.forName("UTF-8")); - tcpLogger.info("receive client:{}, data length:{}",body, body.length()); - Transmission transmission = new Transmission(body); - if("BP".equals(transmission.getHeader())){ - //底部拍照 - Order order = emptyCheckService.visualJudgment(transmission,"BP",0); - - //KsecInfo ksecInfo = emptyCheckService.getKSECWriteByOrder(order,street); - //KsecNettyClient.write(ksecInfo); - - }else if("HB".equals(transmission.getHeader())){ - //心跳 - ClientChanel.connect(transmission.getSRMNumber(),ctx.channel()); - - }else if("TP".equals(transmission.getHeader())){ - //顶部拍照 - Order order = emptyCheckService.visualJudgment(transmission,"TP",1); + try { + String body = in.toString(Charset.forName("UTF-8")); + tcpLogger.info("receive client:{}, data length:{}",body, body.length()); + Transmission transmission = new Transmission(body); + if("BP".equals(transmission.getHeader())){ + //底部拍照 + Order order = emptyCheckService.visualJudgment(transmission,"BP",0); + + //KsecInfo ksecInfo = emptyCheckService.getKSECWriteByOrder(order,street); + //KsecNettyClient.write(ksecInfo); + + }else if("HB".equals(transmission.getHeader())){ + //心跳 + ClientChanel.connect(transmission.getSRMNumber(),ctx.channel()); + + }else if("TP".equals(transmission.getHeader())){ + //顶部拍照 + Order order = emptyCheckService.visualJudgment(transmission,"TP",1); // KsecInfo ksecInfo = emptyCheckService.getKSECWriteByOrder(order,street); // KsecNettyClient.write(ksecInfo); - }else if("LC".equals(transmission.getHeader())){ - //光源控制 + }else if("LC".equals(transmission.getHeader())){ + //光源控制 // ClientChanel.connect(transmission.getSRMNumber(),ctx.channel()); // ClientChanel.sendMessage(transmission.getSRMNumber(),transmission.getBody()); - }else if("DC".equals(transmission.getHeader())){ - //断连 - ClientChanel.disConnect(transmission.getSRMNumber()); - //ClientChanel.sendMessage(transmission.getSRMNumber(),transmission.getBody()); - } + }else if("DC".equals(transmission.getHeader())){ + //断连 + ClientChanel.disConnect(transmission.getSRMNumber()); + //ClientChanel.sendMessage(transmission.getSRMNumber(),transmission.getBody()); + } // else if("ED".equals(transmission.getHeader())){ // //底部报警 // @@ -119,6 +145,11 @@ public class Decoder extends DelimiterBasedFrameDecoder { // emptyCheckService.visualJudgmentStatus(transmission,1); // //ClientChanel.sendMessage(transmission.getSRMNumber(),transmission.getBody()); // } + } catch (Exception e) { + log.error("ClientRunnable process error", e); + } finally { + in.release(); // 确保无论如何都释放 ByteBuf,修复内存泄漏 + } } } diff --git a/web/src/main/java/com/zhehekeji/web/service/client/NettyConnectHandler.java b/web/src/main/java/com/zhehekeji/web/service/client/NettyConnectHandler.java index fe55324..a1904be 100644 --- a/web/src/main/java/com/zhehekeji/web/service/client/NettyConnectHandler.java +++ b/web/src/main/java/com/zhehekeji/web/service/client/NettyConnectHandler.java @@ -41,4 +41,10 @@ public class NettyConnectHandler extends ChannelInboundHandlerAdapter { ClientChanel.disConnect(ID); } + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { + log.error("NettyConnectHandler exception, closing channel", cause); + ctx.close(); + } + } diff --git a/web/src/main/java/com/zhehekeji/web/service/client/Transmission.java b/web/src/main/java/com/zhehekeji/web/service/client/Transmission.java index a4bc542..7c2fda3 100644 --- a/web/src/main/java/com/zhehekeji/web/service/client/Transmission.java +++ b/web/src/main/java/com/zhehekeji/web/service/client/Transmission.java @@ -11,7 +11,7 @@ public class Transmission { private String SRMNumber; - private Boolean success; + private String taskId; private String url; private String isNg; private Boolean ngBoolean = false; @@ -33,7 +33,7 @@ public class Transmission { this.SRMNumber= strings[1]; } if(strings.length >= 4){ - this.success= !strings[2].equals("0"); + this.taskId= strings[2]; this.url= strings[3]; } if(strings.length >= 5){ diff --git a/web/src/main/java/com/zhehekeji/web/service/cron/PLCConnectionExample.java b/web/src/main/java/com/zhehekeji/web/service/cron/PLCConnectionExample.java index 25c6e57..b3476cb 100644 --- a/web/src/main/java/com/zhehekeji/web/service/cron/PLCConnectionExample.java +++ b/web/src/main/java/com/zhehekeji/web/service/cron/PLCConnectionExample.java @@ -258,6 +258,7 @@ public class PLCConnectionExample { } private static final int POLL_INTERVAL = 300; // 查询间隔,单位:毫秒 + private static final int PLC_READ_TIMEOUT = 1; // PLC 读超时,单位:秒 // 读数据 @Scheduled(fixedDelay = POLL_INTERVAL) void server(){ @@ -319,10 +320,10 @@ public class PLCConnectionExample { return 0; } public synchronized boolean writePlcDataTaskId(String key){ - log.info("写入任务号:" + key); - for (String key1 : taskMap.keySet()){ - log.info("taskMap key1:" + key1 + " value:" + taskMap.get(key1)); - } + log.info("写入任务号:" + key+ " value:" + taskMap.get(key)); +// for (String key1 : taskMap.keySet()){ +// log.info("taskMap key1:" + key1 + " value:" + taskMap.get(key1)); +// } int value = taskMap.get(key); if (value == 0) { // 已经写入完成 @@ -362,39 +363,48 @@ public class PLCConnectionExample { return false; } /** - * 批量读取所有C1/C2/C3地址的任务号 + * 批量读取所有C1/C2/C3地址的任务号(带超时保护) * @return Map,例如:{"001-C1": 100, "001-C2": 200, ...} */ public Map readPlcDataTaskIds(){ S7Client client = getConnection(); + Future> future = null; try { - // 批量读取0-44字节区域 - byte[] buffer = new byte[48]; - int result = client.ReadArea(S7.S7AreaDB, dbNumber, 0, 48, buffer); - - if (result != 0) { - updateConnection(client); - log.info("批量读取失败,错误码: " + result); - return new HashMap<>(); - } + future = executorService.submit(() -> { + // 批量读取0-44字节区域 + byte[] buffer = new byte[48]; + int result = client.ReadArea(S7.S7AreaDB, dbNumber, 0, 48, buffer); + + if (result != 0) { + log.info("批量读取失败,错误码: {}", result); + return null; // null 表示读取失败 + } - // 解析所有C1/C2/C3地址的值 - Map resultMap = new HashMap<>(); - for (String key : addressMap.keySet()) { - if (key.contains("out") || !key.contains("C")) { - continue; + // 解析所有C1/C2/C3地址的值 + Map resultMap = new HashMap<>(); + for (String key : addressMap.keySet()) { + if (key.contains("out") || !key.contains("C")) { + continue; + } + int offset = addressMap.get(key); + int value = S7.GetDIntAt(buffer, offset); + resultMap.put(key, value); } - int offset = addressMap.get(key); - int value = S7.GetDIntAt(buffer, offset); - resultMap.put(key, value); - } + return resultMap; + }); - return resultMap; + Map result = future.get(PLC_READ_TIMEOUT, TimeUnit.SECONDS); + returnConnection(client); + return result != null ? result : new HashMap<>(); + } catch (TimeoutException e) { + log.error("批量读取 PLC 超时(>{}秒),强制断开并更新连接", PLC_READ_TIMEOUT); + future.cancel(true); // 中断阻塞的 ReadArea 调用 + updateConnection(client); + return new HashMap<>(); } catch (Exception e) { log.error("批量读取异常", e); + updateConnection(client); return new HashMap<>(); - } finally { - returnConnection(client); } } diff --git a/web/src/main/java/com/zhehekeji/web/service/cron/ZlmService.java b/web/src/main/java/com/zhehekeji/web/service/cron/ZlmService.java index 8c5ab2c..8a57a38 100644 --- a/web/src/main/java/com/zhehekeji/web/service/cron/ZlmService.java +++ b/web/src/main/java/com/zhehekeji/web/service/cron/ZlmService.java @@ -164,7 +164,7 @@ public class ZlmService { if (result != null && Integer.parseInt(result.get("code").toString()) == 0) { Map data = (Map) result.get("data"); String rootPath = data.get("rootPath").toString(); - rootPath = rootPath.replace("E:\\data", "http://127.0.0.1:9007/api"); + rootPath = rootPath.replace("E:\\data\\mp4", ""); List paths = (List) data.get("paths"); // 构建完整路径 diff --git a/web/src/main/java/com/zhehekeji/web/service/ksec/KescNettyHandler.java b/web/src/main/java/com/zhehekeji/web/service/ksec/KescNettyHandler.java index 100ce53..76af98d 100644 --- a/web/src/main/java/com/zhehekeji/web/service/ksec/KescNettyHandler.java +++ b/web/src/main/java/com/zhehekeji/web/service/ksec/KescNettyHandler.java @@ -65,5 +65,10 @@ public class KescNettyHandler extends ChannelInboundHandlerAdapter { } + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { + log.error("KescNettyHandler exception, closing channel", cause); + ctx.close(); + } } diff --git a/web/src/main/java/com/zhehekeji/web/service/ksec/KsecDecoder.java b/web/src/main/java/com/zhehekeji/web/service/ksec/KsecDecoder.java index dbd7885..6ab54d9 100644 --- a/web/src/main/java/com/zhehekeji/web/service/ksec/KsecDecoder.java +++ b/web/src/main/java/com/zhehekeji/web/service/ksec/KsecDecoder.java @@ -15,6 +15,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.util.StringUtils; +import io.netty.util.ReferenceCountUtil; import java.nio.charset.Charset; import java.time.LocalDate; import java.util.HashMap; @@ -38,7 +39,15 @@ public class KsecDecoder extends DelimiterBasedFrameDecoder { private static String lastLotnum; - private static ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(7,21,30, TimeUnit.MILLISECONDS,new ArrayBlockingQueue<>(20000)); + /** + * 隔离的线程池:使用 CallerRunsPolicy 反压策略, + * 队列满时在 Netty I/O 线程中直接执行,防止无限堆积 ByteBuf 撑爆内存 + */ + private static ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor( + 7, 21, 30, TimeUnit.SECONDS, + new ArrayBlockingQueue<>(2000), // 队列从 20000 降到 2000,限制最大堆积 + new ThreadPoolExecutor.CallerRunsPolicy() + ); private PlcService plcService; @@ -55,10 +64,31 @@ public class KsecDecoder extends DelimiterBasedFrameDecoder { return null; } KescRunnable kescRunnable = new KescRunnable(in,ctx,plcService); - threadPoolExecutor.execute(kescRunnable); + try { + threadPoolExecutor.execute(kescRunnable); + } catch (Exception e) { + log.error("KsecDecoder thread pool reject, release ByteBuf", e); + in.release(); // 线程池任务被拒绝时必须手动释放 ByteBuf + } return null; } + /** + * 通道断开时清理父类 DelimiterBasedFrameDecoder 内部累积的 ByteBuf + */ + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + log.debug("KsecDecoder channelInactive, release accumulated buffers"); + // 调用父类的 channelInactive,让 DelimiterBasedFrameDecoder 清理内部缓冲区 + super.channelInactive(ctx); + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { + log.error("KsecDecoder exception, closing channel", cause); + ctx.close(); + } + public static class KescRunnable implements Runnable{ private ByteBuf in; @@ -81,61 +111,61 @@ public class KsecDecoder extends DelimiterBasedFrameDecoder { @Override public void run() { - String body = in.toString(Charset.forName("UTF-8")); - tcpLogger.info(body); - if (body.startsWith("<")){ - // 去掉首尾标识符 - body = body.substring(1, body.length()); - KsecInfo ksecInfo = JSONObject.parseObject(body, KsecInfo.class); - if (Cmd.A.name().equals(ksecInfo.getType())) { - in.release(); - return ; - } - KsecDataInfo dataInfo = ksecInfo.getData(); - plcService.upStreet(dataInfo); - String srmNumber = null; - String cmdName = null; - if(dataInfo != null){ - LocalDate now = LocalDate.now(); - if(dateMap.get(dataInfo.getTaskId()) == null){ - dateMap.put(dataInfo.getTaskId(),now); - }else { - now = dateMap.get(dataInfo.getTaskId()); - } - srmNumber = dataInfo.getSRMNumber(); - cmdName = dataInfo.getCmdName(); - } - if (Cmd.A.name().equals(ksecInfo.getType())) { - //心跳 - log.debug("receieve heart "); - } else if (Cmd.B.name().equals(ksecInfo.getType())) { - - //任务 - if (Cmd.B1.name().equals(cmdName)) { - //B1 开始,开始录像 - plcService.orderStart(dataInfo); - } else if (Cmd.B2.name().equals(cmdName)) { - //B2 C4 一起发的,需要停止等B2 - - plcService.orderStop(dataInfo); - dateMap.remove(dataInfo.getTaskId()); + try { + String body = in.toString(Charset.forName("UTF-8")); + tcpLogger.info(body); + if (body.startsWith("<")){ + // 去掉首尾标识符 + body = body.substring(1, body.length()); + KsecInfo ksecInfo = JSONObject.parseObject(body, KsecInfo.class); + if (Cmd.A.name().equals(ksecInfo.getType())) { + return ; } - } else if (Cmd.C.name().equals(ksecInfo.getType())) { - - //动作 - String code = dataInfo.getCmdName(); - log.info("action code,{},orderInfo:{}", code, dataInfo.toString()); - if (Cmd.isBaseAction(code)) { - //执行动作,需要保存执行到第几步了 - Integer times = GoodsActionTimes.put(dataInfo.getSRMNumber()+"_"+dataInfo.getTaskId()); - plcService.action(dataInfo,times); - - }else { - log.info("other C code :{}",code); + KsecDataInfo dataInfo = ksecInfo.getData(); + plcService.upStreet(dataInfo); + String srmNumber = null; + String cmdName = null; + if(dataInfo != null){ + LocalDate now = LocalDate.now(); + if(dateMap.get(dataInfo.getTaskId()) == null){ + dateMap.put(dataInfo.getTaskId(),now); + }else { + now = dateMap.get(dataInfo.getTaskId()); + } + srmNumber = dataInfo.getSRMNumber(); + cmdName = dataInfo.getCmdName(); } - } else if (Cmd.D.name().equals(ksecInfo.getType())) { - - //柳州去掉告警 + if (Cmd.A.name().equals(ksecInfo.getType())) { + //心跳 + log.debug("receieve heart "); + } else if (Cmd.B.name().equals(ksecInfo.getType())) { + + //任务 + if (Cmd.B1.name().equals(cmdName)) { + //B1 开始,开始录像 + plcService.orderStart(dataInfo); + } else if (Cmd.B2.name().equals(cmdName)) { + //B2 C4 一起发的,需要停止等B2 + + plcService.orderStop(dataInfo); + dateMap.remove(dataInfo.getTaskId()); + } + } else if (Cmd.C.name().equals(ksecInfo.getType())) { + + //动作 + String code = dataInfo.getCmdName(); + log.info("action code,{},orderInfo:{}", code, dataInfo.toString()); + if (Cmd.isBaseAction(code)) { + //执行动作,需要保存执行到第几步了 + Integer times = GoodsActionTimes.put(dataInfo.getSRMNumber()+"_"+dataInfo.getTaskId()); + plcService.action(dataInfo,times); + + }else { + log.info("other C code :{}",code); + } + } else if (Cmd.D.name().equals(ksecInfo.getType())) { + + //柳州去掉告警 // String code = dataInfo.getCmdName(); // if(code.equals(Cmd.D1.name())){ // log.info("plcId:{},warn start",plcCmdInfo.getPlcId()); @@ -153,11 +183,15 @@ public class KsecDecoder extends DelimiterBasedFrameDecoder { // log.info("other D code :{}",code); // } + } + //找到该货位的最后一张照片与现在的照片比照 + //plcService.recordStock(plcCmdInfo, dataInfo.getCode(), 0, 0); } - //找到该货位的最后一张照片与现在的照片比照 - //plcService.recordStock(plcCmdInfo, dataInfo.getCode(), 0, 0); + } catch (Exception e) { + log.error("KescRunnable process error", e); + } finally { + in.release(); // 确保无论如何都释放 ByteBuf } - in.release(); } } } diff --git a/web/src/main/java/com/zhehekeji/web/service/robotic/HeartNettyClientHandler.java b/web/src/main/java/com/zhehekeji/web/service/robotic/HeartNettyClientHandler.java index 8c24a02..65a6a43 100644 --- a/web/src/main/java/com/zhehekeji/web/service/robotic/HeartNettyClientHandler.java +++ b/web/src/main/java/com/zhehekeji/web/service/robotic/HeartNettyClientHandler.java @@ -66,5 +66,10 @@ public class HeartNettyClientHandler extends ChannelInboundHandlerAdapter { } + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { + log.error("streetId:{} HeartNettyClientHandler exception, closing channel", streetId, cause); + ctx.close(); + } } diff --git a/web/src/main/java/com/zhehekeji/web/service/robotic/ProtocolDecoder.java b/web/src/main/java/com/zhehekeji/web/service/robotic/ProtocolDecoder.java index 46b416c..50106ba 100644 --- a/web/src/main/java/com/zhehekeji/web/service/robotic/ProtocolDecoder.java +++ b/web/src/main/java/com/zhehekeji/web/service/robotic/ProtocolDecoder.java @@ -44,17 +44,17 @@ public class ProtocolDecoder extends FixedLengthFrameDecoder { if (in == null) { return null; } - byte l = in.readByte(); - byte p = in.readByte(); - byte len1 = in.readByte(); - byte len2 = in.readByte(); - if (l != 76 && p != 80 && len1 != 4 && len2 != 3) { - //不是包头 丢 - return null; - } - ByteBuf plc = in.readBytes(6); - String plcId = plc.toString(Charset.defaultCharset()); - //CharSequence plc = in.readBytes(6, Charset.defaultCharset()); + try { + byte l = in.readByte(); + byte p = in.readByte(); + byte len1 = in.readByte(); + byte len2 = in.readByte(); + if (l != 76 && p != 80 && len1 != 4 && len2 != 3) { + //不是包头 丢 + return null; + } + ByteBuf plc = in.readBytes(6); + String plcId = plc.toString(Charset.defaultCharset()); //String plcId = plc.toString(); // OA=心跳 OB=工单 OC=任务 OD=告警 @@ -157,9 +157,23 @@ public class ProtocolDecoder extends FixedLengthFrameDecoder { plcService.warnStop(plcId); } } + } finally { in.release(); - return null; + } + return null; + + } + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + log.debug("ProtocolDecoder channelInactive, clean FixedLengthFrameDecoder buffers"); + super.channelInactive(ctx); + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { + log.error("ProtocolDecoder exception, closing channel", cause); + ctx.close(); } /** diff --git a/web/src/main/java/com/zhehekeji/web/service/sick/SickClientHandler.java b/web/src/main/java/com/zhehekeji/web/service/sick/SickClientHandler.java index 1a86379..915e225 100644 --- a/web/src/main/java/com/zhehekeji/web/service/sick/SickClientHandler.java +++ b/web/src/main/java/com/zhehekeji/web/service/sick/SickClientHandler.java @@ -44,4 +44,10 @@ public class SickClientHandler extends ChannelInboundHandlerAdapter { //nettyClient.reconnect(sensorId); } + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { + log.error("sensorId:{} exception, closing channel", sensorId, cause); + ctx.close(); + } + } diff --git a/web/src/main/java/com/zhehekeji/web/service/sick/SickDecoder.java b/web/src/main/java/com/zhehekeji/web/service/sick/SickDecoder.java index b148279..5178306 100644 --- a/web/src/main/java/com/zhehekeji/web/service/sick/SickDecoder.java +++ b/web/src/main/java/com/zhehekeji/web/service/sick/SickDecoder.java @@ -60,6 +60,19 @@ public class SickDecoder extends LineBasedFrameDecoder { return null; } + /** + * 通道断开时清理父类 LineBasedFrameDecoder 内部累积的 ByteBuf + */ + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + log.debug("SickDecoder sensorId:{} channelInactive, clean buffers", sensorId); + super.channelInactive(ctx); + } + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { + log.error("SickDecoder sensorId:{} exception, closing channel", sensorId, cause); + ctx.close(); + } }