feat: 新增作息报时器功能并替换短链接前缀为/to/

- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能
- 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题
- 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链
- 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义
- 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接
- 新增多个调试和集成测试脚本,保障数据安全和功能回归
This commit is contained in:
yangxiangyuan
2026-10-09 14:04:37 +08:00
parent ac475b6659
commit a3e8911deb
131 changed files with 7695 additions and 60 deletions
@@ -0,0 +1,360 @@
# -*- coding: utf-8 -*-
# ============================================================
# tool_crop_mall_logos.py - 宝龙美食打卡 lab · 导视牌 logo 重裁脚本(方案 A)
#
# 病根:原 images/ 是按"等分网格"从斜拍照片硬裁的,透视变形导致越靠边错位越大,
# 裁切图带白底店名标签、串邻格、切边。
#
# 算法流程(经典 CV,离线确定可重复跑):
# 1) 展板四边形检测(亮板 vs 暗背景 OTSU + 最大轮廓 approxPolyDP)→ 四点透视矫正拉正
# 2) 置信照片块检测:HSV 颜色掩膜 (饱和度高 OR 暗) → 闭/开运算 → 连通域,
# 只保留尺寸落在照片先验区间内的"置信块"(过曝区检不出就检不出,不强求)
# 3) 全局网格拟合:导视牌是印刷规整网格,矫正后行/列等距 ——
# 用置信块的最小二乘拟合 行顶线 row_top(r) 与 列中心线 col_center(c),
# 过曝检不出的格子直接按几何矩形取,天然不错位
# 4) 几何兜底格先做"内容密度"校验:空白板面(如 r2c10「待确认」牌上不存在)跳过,
# 有边框/文字/图案的(如商业街白框)保留
# 5) 输出统一宽度 jpg + 拼版预览图 + report.json,供人工 / qwen-vl 抽检
#
# 用法:
# python tool_crop_mall_logos.py # 默认参数直接跑
# python tool_crop_mall_logos.py --pad 4 # 调整裁切外扩像素
# python tool_crop_mall_logos.py --dry-run # 只出预览和报告,不写 images/
# ============================================================
import argparse
import json
import os
import sys
import cv2
import numpy as np
DEFAULT_SRC = r'D:\Temp文件\baolong-mall\照片.jpg'
DEFAULT_OUT = r'd:\Trae_Files\TRAE-Toolbox\public\tools\thought_lab\labs\mall_food_checkin\images'
DEFAULT_SEED = r'd:\Trae_Files\TRAE-Toolbox\src\server\thought_lab\labs\mall_food_checkin\shops_seed.json'
DEFAULT_PREVIEW = r'd:\Trae_Files\TRAE-Toolbox\dev_test_scripts\debug\mall_crop_contact.jpg'
DEFAULT_REPORT = r'd:\Trae_Files\TRAE-Toolbox\dev_test_scripts\debug\mall_crop_report.json'
ROWS, COLS = 7, 11
def imread_u(path):
# Windows 下 cv2.imread 不支持中文路径,用 np.fromfile + imdecode 兜底
data = np.fromfile(path, dtype=np.uint8)
return cv2.imdecode(data, cv2.IMREAD_COLOR)
def imwrite_u(path, img, params=None):
ext = os.path.splitext(path)[1] or '.jpg'
ok, buf = cv2.imencode(ext, img, params or [])
if not ok:
return False
buf.tofile(path)
return True
def order_points(pts):
# 排序为 tl, tr, br, bl
pts = np.array(pts, dtype='float32')
s = pts.sum(axis=1)
d = np.diff(pts, axis=1).ravel()
tl = pts[np.argmin(s)]
br = pts[np.argmax(s)]
tr = pts[np.argmin(d)]
bl = pts[np.argmax(d)]
return np.array([tl, tr, br, bl], dtype='float32')
def detect_board_quad(img):
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
blur = cv2.GaussianBlur(gray, (7, 7), 0)
_, th = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
th = cv2.morphologyEx(th, cv2.MORPH_CLOSE, np.ones((25, 25), np.uint8))
cnts, _ = cv2.findContours(th, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not cnts:
raise RuntimeError('未找到展板轮廓')
c = max(cnts, key=cv2.contourArea)
peri = cv2.arcLength(c, True)
approx = cv2.approxPolyDP(c, 0.02 * peri, True)
if len(approx) == 4:
return order_points(approx.reshape(4, 2))
rect = cv2.minAreaRect(c)
return order_points(cv2.boxPoints(rect))
def warp_board(img, quad):
(tl, tr, br, bl) = quad
w = int(max(np.linalg.norm(tr - tl), np.linalg.norm(br - bl)))
h = int(max(np.linalg.norm(bl - tl), np.linalg.norm(br - tr)))
m = cv2.getPerspectiveTransform(quad, np.array([[0, 0], [w - 1, 0], [w - 1, h - 1], [0, h - 1]], dtype='float32'))
return cv2.warpPerspective(img, m, (w, h))
def detect_confident_blocks(warped):
"""颜色掩膜 + 尺寸先验,只返回高置信照片块(过曝区检不出不强求)"""
hsv = cv2.cvtColor(warped, cv2.COLOR_BGR2HSV)
s = hsv[:, :, 1]
v = hsv[:, :, 2]
mask = (((s > 45) | (v < 140)).astype(np.uint8)) * 255
mask[:60, :] = 0
mask[-60:, :] = 0
mask[:, :60] = 0
mask[:, -60:] = 0
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((9, 9), np.uint8))
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((7, 7), np.uint8))
n, labels, stats, cents = cv2.connectedComponentsWithStats(mask, 8)
h, w = warped.shape[:2]
blocks = []
for i in range(1, n):
x, y, bw, bh, area = stats[i]
cx, cy = cents[i]
if area < 30000:
continue
if cy < 0.17 * h or cx < 0.06 * w:
continue
if not (180 <= bw <= 360 and 150 <= bh <= 300):
continue
blocks.append({'x': int(x), 'y': int(y), 'w': int(bw), 'h': int(bh),
'cx': float(cx), 'cy': float(cy), 'area': int(area)})
return blocks
def fit_grid(blocks):
"""用置信块拟合全局网格:行顶线 / 列中心线 / 照片宽高
列:全局 cx 聚类(不依赖"检满 11 格的行",过曝行缺块也不错位)
行:cy 聚类 + 行距推算行号(缺行不影响编号)"""
# ---- 列:全局 cx 聚类 ----
by_cx = sorted(blocks, key=lambda b: b['cx'])
col_clusters = []
for b in by_cx:
if col_clusters and b['cx'] - col_clusters[-1][-1]['cx'] < 120:
col_clusters[-1].append(b)
else:
col_clusters.append([b])
if len(col_clusters) != COLS:
raise RuntimeError('列聚类=%d(期望 %d)' % (len(col_clusters), COLS))
col_center = [float(np.mean([b['cx'] for b in c])) for c in col_clusters]
# ---- 行:cy 聚类 + 行距推号 ----
by_cy = sorted(blocks, key=lambda b: b['cy'])
row_clusters = []
for b in by_cy:
if row_clusters and b['cy'] - row_clusters[-1][-1]['cy'] < 120:
row_clusters[-1].append(b)
else:
row_clusters.append([b])
cys = [float(np.mean([b['cy'] for b in c])) for c in row_clusters]
diffs = [cys[i + 1] - cys[i] for i in range(len(cys) - 1)]
step = float(np.median([d for d in diffs if d < 400])) if diffs else 290.0
row_idx = [int(round((cy - cys[0]) / step)) for cy in cys]
if len(set(row_idx)) != len(row_idx) or max(row_idx) >= ROWS or min(row_idx) < 0:
raise RuntimeError('行聚类异常: %s' % row_idx)
# ---- 行顶线最小二乘拟合(检出的行 -> 预测全部 7 行)----
pts = [(float(ri), float(np.mean([b['y'] for b in c]))) for ri, c in zip(row_idx, row_clusters)]
if len(pts) >= 2:
ra, rb = np.polyfit([p[0] for p in pts], [p[1] for p in pts], 1)
row_top = [float(rb + ra * r) for r in range(ROWS)]
else:
row_top = [pts[0][1] + step * r for r in range(ROWS)]
pw = float(np.median([b['w'] for b in blocks]))
ph = float(np.median([b['h'] for b in blocks]))
grid = {}
for ri, c in zip(row_idx, row_clusters):
for b in c:
ci = int(np.argmin([abs(cc - b['cx']) for cc in col_center]))
key = (ri, ci)
if key not in grid or b['area'] > grid[key]['area']:
grid[key] = b
return {'row_top': row_top, 'col_center': col_center, 'pw': pw, 'ph': ph,
'grid': grid, 'rows_found': sorted(set(row_idx)), 'step': step}
def trim_label(crop):
"""裁掉底部白底店名标签条 —— 间隙定位法(逐行扫描对死区/剖面重叠太脆弱):
1) 向量化行剖面:gap 行=全宽白(dark<0.03 且 mean>=180);text 行=dark 0.08~0.7 且 mean 100~215
2) 在底部 50% 内找连续 gap 段,自底向上取第一个同时满足以下条件的段作为裁切线:
a. 段下方有 >=8 个 text 行(标签文字)
b. 最后一个 text 行距裁切图底边 <=20 行(标签贴着底边;商业街白框的文字在格子中部,被排除)
c. 段上方 10 行内 gap 行 <5(上面是照片内容,不是另一段白)
找不到合格间隙 → 不裁。"""
g = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
h = g.shape[0]
if h < 40:
return crop
gf = g.astype(np.float32)
dark = (gf < 160).mean(axis=1)
mean = gf.mean(axis=1)
# 列结构指标:文字行 dark 集中在中部(字),间隙/阴影行全宽均匀分布
w = g.shape[1]
c0, c1 = int(w * 0.2), int(w * 0.8)
dark_c = (gf[:, c0:c1] < 160).mean(axis=1)
dark_o = ((gf[:, :c0] < 160).sum(axis=1) + (gf[:, c1:] < 160).sum(axis=1)) / float(w - (c1 - c0))
ratio = (dark_c + 0.004) / (dark_o + 0.004)
# 阈值按实测剖面放宽:角落阴影区标签白底 mean 仅 120~180、间隙行 dark 到 0.09;
# 稀疏字行(DQ 两字 dark~0.05)靠 ratio 与阴影白区分
is_text = (dark >= 0.04) & (mean <= 220) & (ratio > 2.5)
is_gap = (dark < 0.12) & (mean >= 165) & (~is_text)
lo = h - 1 - int(h * 0.5)
runs = []
y = h - 1
while y > lo:
if is_gap[y]:
y2 = y
while y2 > lo and is_gap[y2]:
y2 -= 1
runs.append((y2 + 1, y))
y = y2
else:
y -= 1
for (start, end) in runs: # runs 自底向上收集,天然从最低段开始
if end - start + 1 < 4: # 标签内部笔画间的假间隙通常只有 1~3 行
continue
below = is_text[end + 1:h] if end + 1 < h else np.zeros(0, bool)
if below.sum() < 8:
continue
last_text = end + 1 + int(np.max(np.nonzero(below)))
below_any = (is_text | is_gap)[end + 1:h] if end + 1 < h else np.zeros(0, bool)
if not below_any.any():
continue
last_below = end + 1 + int(np.max(np.nonzero(below_any)))
# 底边允许一段阴影带(既非 text 也非 gap);标签文字与底边之间只允许留白/阴影
if (h - 1) - last_below > 20:
continue
# 文字与底边内容之间允许留白/阴影:实测最大 32 行(r6c0 兜底矩形探到板面);
# 商业街白框的文字距底边 60 行,仍被排除
if last_below - last_text > 35:
continue
above = is_gap[max(0, start - 10):start]
if above.sum() >= 5:
continue
return crop[:start]
return crop
def region_has_content(warped, x, y, w, h):
# 空白板面平滑(边缘密度/标准差低);有 logo/边框/文字的区域高
hh, ww = warped.shape[:2]
x0, y0 = max(0, x), max(0, y)
x1, y1 = min(ww, x + w), min(hh, y + h)
if x1 - x0 < 20 or y1 - y0 < 20:
return False
roi = cv2.cvtColor(warped[y0:y1, x0:x1], cv2.COLOR_BGR2GRAY)
edges = cv2.Canny(roi, 60, 160)
density = float(np.count_nonzero(edges)) / float(edges.size)
return density > 0.006 or float(np.std(roi)) > 24
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--src', default=DEFAULT_SRC)
ap.add_argument('--out', default=DEFAULT_OUT)
ap.add_argument('--seed', default=DEFAULT_SEED)
ap.add_argument('--preview', default=DEFAULT_PREVIEW)
ap.add_argument('--report', default=DEFAULT_REPORT)
ap.add_argument('--pad', type=int, default=2)
ap.add_argument('--width', type=int, default=440)
ap.add_argument('--dry-run', action='store_true')
args = ap.parse_args()
img = imread_u(args.src)
if img is None:
print('读取源照片失败:', args.src)
sys.exit(1)
seed = json.load(open(args.seed, encoding='utf-8'))
ids = [s['id'] for s in seed['shops']]
quad = detect_board_quad(img)
warped = warp_board(img, quad)
wh, ww = warped.shape[:2]
print('透视矫正完成: %dx%d' % (ww, wh))
blocks = detect_confident_blocks(warped)
print('置信照片块: %d 个' % len(blocks))
if len(blocks) < 20:
print('置信块太少,终止(避免误裁覆盖好图)')
sys.exit(2)
g = fit_grid(blocks)
print('网格拟合: 行=%s 行距=%.1f 照片=%.0fx%.0f' % (g['rows_found'], g['step'], g['pw'], g['ph']))
report = {'warped_size': [ww, wh], 'quad': np.round(quad).astype(int).tolist(),
'confident': len(blocks), 'detected': [], 'fallback': [], 'skipped': []}
os.makedirs(args.out, exist_ok=True)
crops = {}
half_w = g['pw'] / 2.0
for ri in range(ROWS):
for ci in range(COLS):
sid = 'r%dc%d' % (ri, ci)
b = g['grid'].get((ri, ci))
if b is not None:
x0 = max(0, b['x'] - args.pad)
y0 = max(0, b['y'] - args.pad)
x1 = min(ww, b['x'] + b['w'] + args.pad)
y1 = min(wh, b['y'] + b['h'] + args.pad)
report['detected'].append(sid)
else:
cx = g['col_center'][ci]
if cx is None:
report['skipped'].append(sid)
continue
x0 = int(max(0, cx - half_w - args.pad))
x1 = int(min(ww, cx + half_w + args.pad))
y0 = int(max(0, g['row_top'][ri] - args.pad))
y1 = int(min(wh, g['row_top'][ri] + g['ph'] + args.pad))
if not region_has_content(warped, x0, y0, x1 - x0, y1 - y0):
report['skipped'].append(sid)
continue
report['fallback'].append(sid)
crop = warped[y0:y1, x0:x1]
if crop.size == 0:
report['skipped'].append(sid)
continue
crop = trim_label(crop)
scale = args.width / float(crop.shape[1])
crop = cv2.resize(crop, (args.width, max(1, int(crop.shape[0] * scale))), interpolation=cv2.INTER_AREA)
crops[sid] = crop
if not args.dry_run:
imwrite_u(os.path.join(args.out, sid + '.jpg'), crop, [cv2.IMWRITE_JPEG_QUALITY, 88])
# 种子中存在但牌子上不存在的 id:清掉旧误裁图,前端显示占位符
if not args.dry_run:
for sid in ids:
if sid not in crops:
p = os.path.join(args.out, sid + '.jpg')
if os.path.exists(p):
os.remove(p)
print('清除旧误裁图:', sid)
# 拼版预览(11 列 x 7 行,带 id 标注)
cw, ch = 160, 118
lab_h = 18
sheet = np.full((ROWS * (ch + lab_h), COLS * cw, 3), 255, np.uint8)
for ri in range(ROWS):
for ci in range(COLS):
sid = 'r%dc%d' % (ri, ci)
x, y = ci * cw, ri * (ch + lab_h)
cv2.putText(sheet, sid, (x + 4, y + 13), cv2.FONT_HERSHEY_SIMPLEX, 0.42, (0, 0, 0), 1)
c = crops.get(sid)
if c is None:
cv2.putText(sheet, 'MISS', (x + 40, y + 70), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
continue
sc = min((cw - 4) / float(c.shape[1]), (ch - 4) / float(c.shape[0]))
cc = cv2.resize(c, (max(1, int(c.shape[1] * sc)), max(1, int(c.shape[0] * sc))))
sheet[y + lab_h:y + lab_h + cc.shape[0], x + 2:x + 2 + cc.shape[1]] = cc
os.makedirs(os.path.dirname(args.preview), exist_ok=True)
imwrite_u(args.preview, sheet, [cv2.IMWRITE_JPEG_QUALITY, 90])
report['written'] = sorted(crops.keys())
os.makedirs(os.path.dirname(args.report), exist_ok=True)
json.dump(report, open(args.report, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
print('裁切完成: 直检 %d / 兜底 %d / 跳过 %d -> %s' % (
len(report['detected']), len(report['fallback']), len(report['skipped']), args.preview))
if report['fallback']:
print('兜底裁切:', report['fallback'])
if report['skipped']:
print('跳过(牌子上不存在):', report['skipped'])
if __name__ == '__main__':
main()