feat: HSAP platform v2 — modular navigation, quality review, audit log, world model simulation

Major changes:
- New frontend (platform/web/): Vite + React 18 + TypeScript + Tailwind
- 4-module navigation: 数据送标 / 模型管理 / 车队管理 / 系统管理
- Data catalog with charts (DMS/ADAS/Lane 3-tab view)
- Quality review workflow (标注质检): Good/Fine/Bad scoring with auto-advance
- Audit enhancements: batch operations, rejection categories, Feishu notifications
- Operation audit log (操作日志)
- World model simulation studio (仿真工坊)
- Dataset version management with snapshots and diff
- ADAS 7-class dataset integration (138K images organized + compressed)
- User management with Feishu integration and pagination
- CRUD/search/filter on all pages, card layout redesign
- PIL-optimized image overlay rendering
- Auto-snapshot on build, in_review workflow stage
- Removed embedded algorithm code (now in workspace)
This commit is contained in:
2026-06-03 11:40:21 +08:00
parent 7c43b44c57
commit e72bc061c5
5487 changed files with 979207 additions and 6197 deletions

View File

@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Export CLRNet to ONNX (bilinear_grid_sample, no GridSample)."""
import argparse
import os
import sys
import types
import numpy as np
import torch
import torch.nn as nn
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
sys.path.insert(0, ROOT)
def _stub_nms_cuda_ext():
"""Export does not need CUDA NMS; stub so clrnet.ops imports on CPU-only hosts."""
if 'clrnet.ops.nms_impl' in sys.modules:
return
stub = types.ModuleType('clrnet.ops.nms_impl')
def _nms_forward(boxes, scores, overlap, top_k):
raise NotImplementedError('NMS runs on CPU after RKNN inference, not in exported graph')
stub.nms_forward = _nms_forward
sys.modules['clrnet.ops.nms_impl'] = stub
_stub_nms_cuda_ext()
from clrnet.utils.config import Config # noqa: E402
from clrnet.models.registry import build_net # noqa: E402
class CLRNetOnnxWrapper(nn.Module):
def __init__(self, net):
super().__init__()
self.net = net
def forward(self, img):
# Detector returns (B, num_priors, 77) in eval mode
return self.net({'img': img})
def _remap_clrernet_keys(key):
"""Map CLRerNet (mmdet) checkpoint keys to Turoad CLRNet-main module names."""
key = key.replace('bbox_head.', 'heads.')
key = key.replace('heads.sample_x_indices', 'heads.sample_x_indexs')
key = key.replace('heads.anchor_generator.prior_embeddings',
'heads.prior_embeddings')
key = key.replace('heads.attention.attention.', 'heads.roi_gather.')
key = key.replace('heads.attention.', 'heads.roi_gather.')
return key
def load_weights(net, path):
ckpt = torch.load(path, map_location='cpu')
if isinstance(ckpt, dict):
if 'net' in ckpt:
state = ckpt['net']
elif 'state_dict' in ckpt:
state = ckpt['state_dict']
else:
state = ckpt
else:
state = ckpt
remapped = {_remap_clrernet_keys(k): v for k, v in state.items()}
missing, unexpected = net.load_state_dict(remapped, strict=False)
print('load_state_dict: missing', len(missing), 'unexpected', len(unexpected))
if missing:
print(' missing sample:', missing[:8])
if unexpected:
print(' unexpected sample:', unexpected[:8])
def compare_outputs(wrapper, dummy, onnx_path):
try:
import onnxruntime as ort
except ImportError:
print('onnxruntime not installed, skip numeric check')
return
wrapper.eval()
with torch.no_grad():
pt_out = wrapper(dummy).numpy()
sess = ort.InferenceSession(onnx_path, providers=['CPUExecutionProvider'])
ort_out = sess.run(None, {'img': dummy.numpy()})[0]
diff = np.abs(pt_out - ort_out).max()
print(f'PyTorch vs ONNX max diff: {diff:.6f}')
print(f'output shape: {pt_out.shape}')
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--config', default='configs/clrnet/clr_dla34_culane.py')
parser.add_argument('--checkpoint', default='models/clrernet_culane_dla34.pth')
parser.add_argument('--output', default='models/clrernet_culane_dla34.onnx')
parser.add_argument('--opset', type=int, default=11)
parser.add_argument('--height', type=int, default=None)
parser.add_argument('--width', type=int, default=None)
parser.add_argument('--check', action='store_true', help='run onnxruntime compare')
args = parser.parse_args()
cfg = Config.fromfile(os.path.join(ROOT, args.config))
h = args.height or cfg.img_h
w = args.width or cfg.img_w
# Avoid downloading ImageNet weights; we load the CLRNet checkpoint below.
if cfg.get('backbone', None) is not None:
cfg.backbone.pretrained = False
net = build_net(cfg)
load_weights(net, os.path.join(ROOT, args.checkpoint))
net.eval()
wrapper = CLRNetOnnxWrapper(net)
dummy = torch.randn(1, 3, h, w)
out_path = os.path.join(ROOT, args.output)
os.makedirs(os.path.dirname(out_path) or '.', exist_ok=True)
print(f'export ONNX: {out_path}')
print(f'input: (1, 3, {h}, {w})')
with torch.no_grad():
test_out = wrapper(dummy)
print(f'output: {tuple(test_out.shape)}')
torch.onnx.export(
wrapper,
dummy,
out_path,
opset_version=args.opset,
input_names=['img'],
output_names=['predictions'],
dynamic_axes={'img': {0: 'batch'}, 'predictions': {0: 'batch'}},
do_constant_folding=True,
)
print('saved:', out_path)
try:
import onnx
model = onnx.load(out_path)
ops = {n.op_type for n in model.graph.node}
print('GridSample in graph:', 'GridSample' in ops)
print('node op types (sample):', sorted(ops)[:20], '...')
except ImportError:
pass
if args.check:
compare_outputs(wrapper, dummy, out_path)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Pre-generate .lines.txt from masks for faster CLRNet training."""
import argparse
import os
import os.path as osp
import sys
import cv2
from tqdm import tqdm
ROOT = osp.dirname(osp.dirname(osp.abspath(__file__)))
sys.path.insert(0, ROOT)
from clrnet.utils.mask_to_lanes import lanes_from_mask, lanes_to_lines_txt
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--data-root", required=True)
ap.add_argument("--list", required=True, help="train_gt.txt path")
ap.add_argument("--sample-y", type=int, nargs="+", default=list(range(710, 150, -10)))
ap.add_argument("--num-lanes", type=int, default=4)
ap.add_argument("--cache-dir", default="cache/mufld_lines")
args = ap.parse_args()
list_path = args.list if osp.isabs(args.list) else osp.join(args.data_root, args.list)
n_ok = 0
with open(list_path) as f:
lines = [ln.strip() for ln in f if ln.strip()]
for line in tqdm(lines):
parts = line.split()
if len(parts) < 2:
continue
img_rel, _ = parts[0].lstrip("/"), parts[1].lstrip("/")
img_path = osp.join(args.data_root, img_rel)
base = img_path[:-4]
out = osp.join(args.data_root, args.cache_dir, osp.relpath(base, args.data_root) + ".lines.txt")
mask_path = osp.join(args.data_root, parts[1].lstrip("/"))
mask = cv2.imread(mask_path, cv2.IMREAD_UNCHANGED)
if mask is None:
continue
if mask.ndim > 2:
mask = mask[:, :, 0]
lanes = lanes_from_mask(mask, args.sample_y, args.num_lanes)
os.makedirs(osp.dirname(out), exist_ok=True)
with open(out, "w") as fp:
fp.write(lanes_to_lines_txt(lanes))
n_ok += 1
print("wrote", n_ok, "lines files under", args.cache_dir)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,126 @@
import json
import numpy as np
import cv2
import os
import argparse
TRAIN_SET = ['label_data_0313.json', 'label_data_0601.json']
VAL_SET = ['label_data_0531.json']
TRAIN_VAL_SET = TRAIN_SET + VAL_SET
TEST_SET = ['test_label.json']
def gen_label_for_json(args, image_set):
H, W = 720, 1280
SEG_WIDTH = 30
save_dir = args.savedir
os.makedirs(os.path.join(args.root, args.savedir, "list"), exist_ok=True)
list_f = open(
os.path.join(args.root, args.savedir, "list",
"{}_gt.txt".format(image_set)), "w")
json_path = os.path.join(args.root, args.savedir,
"{}.json".format(image_set))
with open(json_path) as f:
for line in f:
label = json.loads(line)
# ---------- clean and sort lanes -------------
lanes = []
_lanes = []
slope = [
] # identify 0th, 1st, 2nd, 3rd, 4th, 5th lane through slope
for i in range(len(label['lanes'])):
l = [(x, y)
for x, y in zip(label['lanes'][i], label['h_samples'])
if x >= 0]
if (len(l) > 1):
_lanes.append(l)
slope.append(
np.arctan2(l[-1][1] - l[0][1], l[0][0] - l[-1][0]) /
np.pi * 180)
_lanes = [_lanes[i] for i in np.argsort(slope)]
slope = [slope[i] for i in np.argsort(slope)]
idx = [None for i in range(6)]
for i in range(len(slope)):
if slope[i] <= 90:
idx[2] = i
idx[1] = i - 1 if i > 0 else None
idx[0] = i - 2 if i > 1 else None
else:
idx[3] = i
idx[4] = i + 1 if i + 1 < len(slope) else None
idx[5] = i + 2 if i + 2 < len(slope) else None
break
for i in range(6):
lanes.append([] if idx[i] is None else _lanes[idx[i]])
# ---------------------------------------------
img_path = label['raw_file']
seg_img = np.zeros((H, W, 3))
list_str = [] # str to be written to list.txt
for i in range(len(lanes)):
coords = lanes[i]
if len(coords) < 4:
list_str.append('0')
continue
for j in range(len(coords) - 1):
cv2.line(seg_img, coords[j], coords[j + 1],
(i + 1, i + 1, i + 1), SEG_WIDTH // 2)
list_str.append('1')
seg_path = img_path.split("/")
seg_path, img_name = os.path.join(args.root, args.savedir,
seg_path[1],
seg_path[2]), seg_path[3]
os.makedirs(seg_path, exist_ok=True)
seg_path = os.path.join(seg_path, img_name[:-3] + "png")
cv2.imwrite(seg_path, seg_img)
seg_path = "/".join([
args.savedir, *img_path.split("/")[1:3], img_name[:-3] + "png"
])
if seg_path[0] != '/':
seg_path = '/' + seg_path
if img_path[0] != '/':
img_path = '/' + img_path
list_str.insert(0, seg_path)
list_str.insert(0, img_path)
list_str = " ".join(list_str) + "\n"
list_f.write(list_str)
def generate_json_file(save_dir, json_file, image_set):
with open(os.path.join(save_dir, json_file), "w") as outfile:
for json_name in (image_set):
with open(os.path.join(args.root, json_name)) as infile:
for line in infile:
outfile.write(line)
def generate_label(args):
save_dir = os.path.join(args.root, args.savedir)
os.makedirs(save_dir, exist_ok=True)
generate_json_file(save_dir, "train_val.json", TRAIN_VAL_SET)
generate_json_file(save_dir, "test.json", TEST_SET)
print("generating train_val set...")
gen_label_for_json(args, 'train_val')
print("generating test set...")
gen_label_for_json(args, 'test')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--root',
required=True,
help='The root of the Tusimple dataset')
parser.add_argument('--savedir',
type=str,
default='seg_label',
help='The root of the Tusimple dataset')
args = parser.parse_args()
generate_label(args)

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Write a calibration image list for RKNN (one absolute path per line)."""
import argparse
import glob
import os
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--glob', default='**/*.jpg', help='glob under --root')
parser.add_argument('--root', default='.', help='search root')
parser.add_argument('--out', default='models/calib_images.txt')
parser.add_argument('--max', type=int, default=20)
args = parser.parse_args()
root = os.path.abspath(args.root)
paths = []
for p in sorted(glob.glob(os.path.join(root, args.glob), recursive=True)):
if os.path.isfile(p):
paths.append(os.path.abspath(p))
if len(paths) >= args.max:
break
if not paths:
raise SystemExit(f'no images under {root} with {args.glob}')
out = os.path.abspath(args.out)
os.makedirs(os.path.dirname(out) or '.', exist_ok=True)
with open(out, 'w') as f:
f.write('\n'.join(paths) + '\n')
print(f'wrote {len(paths)} paths -> {out}')
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""ONNX -> RKNN for CLRNet (platform rk3576). Requires rknn-toolkit2 on x86 Linux."""
import argparse
import os
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--onnx', default='models/clrernet_culane_dla34.onnx')
parser.add_argument('--rknn', default='models/clrernet_culane_dla34_rk3576.rknn')
parser.add_argument('--platform', default='rk3576')
parser.add_argument('--dtype', default='i8', choices=['i8', 'fp'])
parser.add_argument('--dataset', default=None,
help='txt list of calibration images (one path per line)')
parser.add_argument('--height', type=int, default=320)
parser.add_argument('--width', type=int, default=800)
args = parser.parse_args()
try:
from rknn.api import RKNN
except ImportError as e:
raise SystemExit(
'Install rknn-toolkit2 in a separate env: pip install rknn-toolkit2\n' + str(e)
) from e
if not args.dataset:
raise SystemExit(
'Provide --dataset with a txt of RGB image paths for INT8 calibration.'
)
rknn = RKNN(verbose=True)
print('config:', args.platform, args.dtype)
rknn.config(mean_values=[[103.939, 116.779, 123.68]],
std_values=[[1, 1, 1]],
target_platform=args.platform,
quantized_dtype='asymmetric_quantized-8' if args.dtype == 'i8' else 'float16')
ret = rknn.load_onnx(model=args.onnx)
if ret != 0:
raise SystemExit('load_onnx failed')
ret = rknn.build(do_quantization=(args.dtype == 'i8'), dataset=args.dataset)
if ret != 0:
raise SystemExit('build failed')
os.makedirs(os.path.dirname(args.rknn) or '.', exist_ok=True)
ret = rknn.export_rknn(args.rknn)
if ret != 0:
raise SystemExit('export_rknn failed')
print('saved:', os.path.abspath(args.rknn))
rknn.release()
if __name__ == '__main__':
main()