|
| 1 | +"""费用条 tick 检测。 |
| 2 | +
|
| 3 | +移植自 reference/ArknightsCostBarRuler-master/ruler/utils.py 的三个函数: |
| 4 | +- find_cost_bar_roi(width, height) -> (x1, x2, y) |
| 5 | +- get_filled_pixel_width(frame, roi) -> int | None(向量化) |
| 6 | +- get_logical_frame(frame, roi, pixel_map) -> int | None |
| 7 | +
|
| 8 | +输入 frame 为 maafw 截图返回的 BGR ndarray (H, W, 3) uint8。 |
| 9 | +白/灰度判定对通道顺序不敏感(|R-G|、|G-B|、全通道>阈值),故 BGR 直接处理无需转换。 |
| 10 | +""" |
| 11 | + |
| 12 | +from __future__ import annotations |
| 13 | + |
| 14 | +import numpy as np |
| 15 | + |
| 16 | +from custom import config |
| 17 | + |
| 18 | +Roi = tuple[int, int, int] # (x1, x2, y) |
| 19 | + |
| 20 | + |
| 21 | +def find_cost_bar_roi(width: int, height: int) -> Roi: |
| 22 | + """根据屏幕分辨率计算费用条 ROI。 |
| 23 | +
|
| 24 | + 参考 1920x1080(config.REF_WIDTH/HEIGHT),按短边等比缩放。 |
| 25 | + """ |
| 26 | + ref_aspect = config.REF_WIDTH / config.REF_HEIGHT |
| 27 | + cur_aspect = width / height |
| 28 | + scale = height / config.REF_HEIGHT if cur_aspect >= ref_aspect else width / config.REF_WIDTH |
| 29 | + |
| 30 | + x1 = width - config.X1_OFFSET_FROM_RIGHT * scale |
| 31 | + x2 = width - config.X2_OFFSET_FROM_RIGHT * scale |
| 32 | + y1 = height - config.Y1_OFFSET_FROM_BOTTOM * scale |
| 33 | + y2 = height - config.Y2_OFFSET_FROM_BOTTOM * scale |
| 34 | + return (round(x1), round(x2), round((y1 + y2) / 2)) |
| 35 | + |
| 36 | + |
| 37 | +def get_filled_pixel_width(frame: np.ndarray, roi: Roi) -> int | None: |
| 38 | + """提取费用条填充像素宽。 |
| 39 | +
|
| 40 | + 双模式(与 CostBarRuler 一致): |
| 41 | + - 普通模式:白像素阈值 > WHITE_THRESHOLD(250)。 |
| 42 | + - 遮罩模式(变暗):> MASKED_WHITE_THRESHOLD(150) 且整体 <= MASKED_MAX_BRIGHTNESS(165)。 |
| 43 | + ROI 行必须为灰度(GRAY_TOLERANCE 内);末端像素非灰度则判定 ROI 无效返回 None。 |
| 44 | +
|
| 45 | + Returns: |
| 46 | + 填充像素宽(0 表示空/未检出),或 None 表示 ROI 无效。 |
| 47 | + """ |
| 48 | + x1, x2, y = roi |
| 49 | + total = x2 - x1 |
| 50 | + if total <= 0: |
| 51 | + return None |
| 52 | + h, w = frame.shape[:2] |
| 53 | + if not (0 <= y < h and 0 <= x1 and x2 <= w): |
| 54 | + return None |
| 55 | + |
| 56 | + row = frame[y, x1:x2].astype(np.int16) # (total, 3) BGR |
| 57 | + c0, c1, c2 = row[:, 0], row[:, 1], row[:, 2] |
| 58 | + gray = (np.abs(c0 - c1) <= config.GRAY_TOLERANCE) & (np.abs(c1 - c2) <= config.GRAY_TOLERANCE) |
| 59 | + |
| 60 | + # 末端像素必须灰度,否则 ROI 无效。 |
| 61 | + if not gray[-1]: |
| 62 | + return None |
| 63 | + |
| 64 | + # --- 普通模式 --- |
| 65 | + white = ( |
| 66 | + (c0 > config.WHITE_THRESHOLD) |
| 67 | + & (c1 > config.WHITE_THRESHOLD) |
| 68 | + & (c2 > config.WHITE_THRESHOLD) |
| 69 | + ) |
| 70 | + if white[-1]: |
| 71 | + return total |
| 72 | + non_end_white = np.where(white[:-1])[0] # 右→左扫到第一个白边 |
| 73 | + if non_end_white.size > 0: |
| 74 | + edge = int(non_end_white[-1]) |
| 75 | + # edge 右侧(扫描经过的填充段)必须全灰度,否则 ROI 含杂色 → 无效。 |
| 76 | + if gray[edge + 1 : -1].all(): |
| 77 | + return edge + 1 |
| 78 | + return None |
| 79 | + |
| 80 | + # --- 遮罩模式回退(filled == 0)--- |
| 81 | + too_bright = ( |
| 82 | + (c0 > config.MASKED_MAX_BRIGHTNESS) |
| 83 | + | (c1 > config.MASKED_MAX_BRIGHTNESS) |
| 84 | + | (c2 > config.MASKED_MAX_BRIGHTNESS) |
| 85 | + ) |
| 86 | + if too_bright[-1]: |
| 87 | + return 0 # 末端过亮,不可能是遮罩模式。 |
| 88 | + masked_white = ( |
| 89 | + (c0 > config.MASKED_WHITE_THRESHOLD) |
| 90 | + & (c1 > config.MASKED_WHITE_THRESHOLD) |
| 91 | + & (c2 > config.MASKED_WHITE_THRESHOLD) |
| 92 | + ) |
| 93 | + if masked_white[-1]: |
| 94 | + return total |
| 95 | + candidates = np.where(masked_white[:-1] & ~too_bright[:-1])[0] |
| 96 | + if candidates.size > 0: |
| 97 | + edge = int(candidates[-1]) |
| 98 | + seg_gray = gray[edge + 1 : -1] |
| 99 | + seg_bright = too_bright[edge + 1 : -1] |
| 100 | + if seg_gray.all() and not seg_bright.any(): |
| 101 | + return edge + 1 |
| 102 | + return 0 |
| 103 | + |
| 104 | + |
| 105 | +def get_logical_frame(frame: np.ndarray, roi: Roi, pixel_map: dict[str, int]) -> int | None: |
| 106 | + """像素宽 → 逻辑帧(via 校准 pixel_map)。 |
| 107 | +
|
| 108 | + 先直接命中,否则在 PIXEL_TOLERANCE(5) 内取最近。未命中返回 None。 |
| 109 | + """ |
| 110 | + pw = get_filled_pixel_width(frame, roi) |
| 111 | + if pw is None: |
| 112 | + return None |
| 113 | + key = str(pw) |
| 114 | + if key in pixel_map: |
| 115 | + return pixel_map[key] |
| 116 | + best_frame: int | None = None |
| 117 | + best_diff = config.PIXEL_TOLERANCE + 1 |
| 118 | + for k, v in pixel_map.items(): |
| 119 | + diff = abs(pw - int(k)) |
| 120 | + if diff < best_diff: |
| 121 | + best_diff = diff |
| 122 | + best_frame = v |
| 123 | + return best_frame if best_diff <= config.PIXEL_TOLERANCE else None |
| 124 | + |
| 125 | + |
| 126 | +def detect(frame: np.ndarray, pixel_map: dict[str, int] | None = None) -> tuple[Roi, int | None]: |
| 127 | + """便捷:算 ROI + 取填充宽(+ 可选逻辑帧)。 |
| 128 | +
|
| 129 | + Returns: |
| 130 | + (roi, logical_frame or None);若 pixel_map 为 None 则第二项为填充像素宽。 |
| 131 | + """ |
| 132 | + h, w = frame.shape[:2] |
| 133 | + roi = find_cost_bar_roi(w, h) |
| 134 | + if pixel_map is None: |
| 135 | + return roi, get_filled_pixel_width(frame, roi) |
| 136 | + return roi, get_logical_frame(frame, roi, pixel_map) |
0 commit comments