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,21 @@
from .transforms import (RandomLROffsetLABEL, RandomUDoffsetLABEL, Resize,
RandomCrop, CenterCrop, RandomRotation, RandomBlur,
RandomHorizontalFlip, Normalize, ToTensor)
from .generate_lane_line import GenerateLaneLine
from .process import Process
__all__ = [
'Process',
'RandomLROffsetLABEL',
'RandomUDoffsetLABEL',
'Resize',
'RandomCrop',
'CenterCrop',
'RandomRotation',
'RandomBlur',
'RandomHorizontalFlip',
'Normalize',
'ToTensor',
'GenerateLaneLine',
]

View File

@@ -0,0 +1,218 @@
import math
import numpy as np
import cv2
import imgaug.augmenters as iaa
from imgaug.augmentables.lines import LineString, LineStringsOnImage
from imgaug.augmentables.segmaps import SegmentationMapsOnImage
from scipy.interpolate import InterpolatedUnivariateSpline
from clrnet.datasets.process.transforms import CLRTransforms
from ..registry import PROCESS
@PROCESS.register_module
class GenerateLaneLine(object):
def __init__(self, transforms=None, cfg=None, training=True):
self.transforms = transforms
self.img_w, self.img_h = cfg.img_w, cfg.img_h
self.num_points = cfg.num_points
self.n_offsets = cfg.num_points
self.n_strips = cfg.num_points - 1
self.strip_size = self.img_h / self.n_strips
self.max_lanes = cfg.max_lanes
self.offsets_ys = np.arange(self.img_h, -1, -self.strip_size)
self.training = training
if transforms is None:
transforms = CLRTransforms(self.img_h, self.img_w)
if transforms is not None:
img_transforms = []
for aug in transforms:
p = aug['p']
if aug['name'] != 'OneOf':
img_transforms.append(
iaa.Sometimes(p=p,
then_list=getattr(
iaa,
aug['name'])(**aug['parameters'])))
else:
img_transforms.append(
iaa.Sometimes(
p=p,
then_list=iaa.OneOf([
getattr(iaa,
aug_['name'])(**aug_['parameters'])
for aug_ in aug['transforms']
])))
else:
img_transforms = []
self.transform = iaa.Sequential(img_transforms)
def lane_to_linestrings(self, lanes):
lines = []
for lane in lanes:
lines.append(LineString(lane))
return lines
def sample_lane(self, points, sample_ys):
# this function expects the points to be sorted
points = np.array(points)
if not np.all(points[1:, 1] < points[:-1, 1]):
raise Exception('Annotaion points have to be sorted')
x, y = points[:, 0], points[:, 1]
# interpolate points inside domain
assert len(points) > 1
interp = InterpolatedUnivariateSpline(y[::-1],
x[::-1],
k=min(3,
len(points) - 1))
domain_min_y = y.min()
domain_max_y = y.max()
sample_ys_inside_domain = sample_ys[(sample_ys >= domain_min_y)
& (sample_ys <= domain_max_y)]
assert len(sample_ys_inside_domain) > 0
interp_xs = interp(sample_ys_inside_domain)
# extrapolate lane to the bottom of the image with a straight line using the 2 points closest to the bottom
two_closest_points = points[:2]
extrap = np.polyfit(two_closest_points[:, 1],
two_closest_points[:, 0],
deg=1)
extrap_ys = sample_ys[sample_ys > domain_max_y]
extrap_xs = np.polyval(extrap, extrap_ys)
all_xs = np.hstack((extrap_xs, interp_xs))
# separate between inside and outside points
inside_mask = (all_xs >= 0) & (all_xs < self.img_w)
xs_inside_image = all_xs[inside_mask]
xs_outside_image = all_xs[~inside_mask]
return xs_outside_image, xs_inside_image
def filter_lane(self, lane):
assert lane[-1][1] <= lane[0][1]
filtered_lane = []
used = set()
for p in lane:
if p[1] not in used:
filtered_lane.append(p)
used.add(p[1])
return filtered_lane
def transform_annotation(self, anno, img_wh=None):
img_w, img_h = self.img_w, self.img_h
old_lanes = anno['lanes']
# removing lanes with less than 2 points
old_lanes = filter(lambda x: len(x) > 1, old_lanes)
# sort lane points by Y (bottom to top of the image)
old_lanes = [sorted(lane, key=lambda x: -x[1]) for lane in old_lanes]
# remove points with same Y (keep first occurrence)
old_lanes = [self.filter_lane(lane) for lane in old_lanes]
# normalize the annotation coordinates
old_lanes = [[[
x * self.img_w / float(img_w), y * self.img_h / float(img_h)
] for x, y in lane] for lane in old_lanes]
# create tranformed annotations
lanes = np.ones(
(self.max_lanes, 2 + 1 + 1 + 2 + self.n_offsets), dtype=np.float32
) * -1e5 # 2 scores, 1 start_y, 1 start_x, 1 theta, 1 length, S+1 coordinates
lanes_endpoints = np.ones((self.max_lanes, 2))
# lanes are invalid by default
lanes[:, 0] = 1
lanes[:, 1] = 0
for lane_idx, lane in enumerate(old_lanes):
if lane_idx >= self.max_lanes:
break
try:
xs_outside_image, xs_inside_image = self.sample_lane(
lane, self.offsets_ys)
except AssertionError:
continue
if len(xs_inside_image) <= 1:
continue
all_xs = np.hstack((xs_outside_image, xs_inside_image))
lanes[lane_idx, 0] = 0
lanes[lane_idx, 1] = 1
lanes[lane_idx, 2] = len(xs_outside_image) / self.n_strips
lanes[lane_idx, 3] = xs_inside_image[0]
thetas = []
for i in range(1, len(xs_inside_image)):
theta = math.atan(
i * self.strip_size /
(xs_inside_image[i] - xs_inside_image[0] + 1e-5)) / math.pi
theta = theta if theta > 0 else 1 - abs(theta)
thetas.append(theta)
theta_far = sum(thetas) / len(thetas)
# lanes[lane_idx,
# 4] = (theta_closest + theta_far) / 2 # averaged angle
lanes[lane_idx, 4] = theta_far
lanes[lane_idx, 5] = len(xs_inside_image)
lanes[lane_idx, 6:6 + len(all_xs)] = all_xs
lanes_endpoints[lane_idx, 0] = (len(all_xs) - 1) / self.n_strips
lanes_endpoints[lane_idx, 1] = xs_inside_image[-1]
new_anno = {
'label': lanes,
'old_anno': anno,
'lane_endpoints': lanes_endpoints
}
return new_anno
def linestrings_to_lanes(self, lines):
lanes = []
for line in lines:
lanes.append(line.coords)
return lanes
def __call__(self, sample):
img_org = sample['img']
line_strings_org = self.lane_to_linestrings(sample['lanes'])
line_strings_org = LineStringsOnImage(line_strings_org,
shape=img_org.shape)
for i in range(30):
if self.training:
mask_org = SegmentationMapsOnImage(sample['mask'],
shape=img_org.shape)
img, line_strings, seg = self.transform(
image=img_org.copy().astype(np.uint8),
line_strings=line_strings_org,
segmentation_maps=mask_org)
else:
img, line_strings = self.transform(
image=img_org.copy().astype(np.uint8),
line_strings=line_strings_org)
line_strings.clip_out_of_image_()
new_anno = {'lanes': self.linestrings_to_lanes(line_strings)}
try:
annos = self.transform_annotation(new_anno,
img_wh=(self.img_w,
self.img_h))
label = annos['label']
lane_endpoints = annos['lane_endpoints']
break
except:
if (i + 1) == 30:
self.logger.critical(
'Transform annotation failed 30 times :(')
exit()
sample['img'] = img.astype(np.float32) / 255.
sample['lane_line'] = label
sample['lanes_endpoints'] = lane_endpoints
sample['gt_points'] = new_anno['lanes']
sample['seg'] = seg.get_arr() if self.training else np.zeros(
img_org.shape)
return sample

View File

@@ -0,0 +1,48 @@
import collections
from clrnet.utils import build_from_cfg
from ..registry import PROCESS
class Process(object):
"""Compose multiple process sequentially.
Args:
process (Sequence[dict | callable]): Sequence of process object or
config dict to be composed.
"""
def __init__(self, processes, cfg):
assert isinstance(processes, collections.abc.Sequence)
self.processes = []
for process in processes:
if isinstance(process, dict):
process = build_from_cfg(process,
PROCESS,
default_args=dict(cfg=cfg))
self.processes.append(process)
elif callable(process):
self.processes.append(process)
else:
raise TypeError('process must be callable or a dict')
def __call__(self, data):
"""Call function to apply processes sequentially.
Args:
data (dict): A result dict contains the data to process.
Returns:
dict: Processed data.
"""
for t in self.processes:
data = t(data)
if data is None:
return None
return data
def __repr__(self):
format_string = self.__class__.__name__ + '('
for t in self.processes:
format_string += '\n'
format_string += f' {t}'
format_string += '\n)'
return format_string

View File

@@ -0,0 +1,311 @@
import random
import cv2
import numpy as np
import torch
import numbers
import collections
from PIL import Image
from ..registry import PROCESS
def to_tensor(data):
"""Convert objects of various python types to :obj:`torch.Tensor`.
Supported types are: :class:`numpy.ndarray`, :class:`torch.Tensor`,
:class:`Sequence`, :class:`int` and :class:`float`.
Args:
data (torch.Tensor | numpy.ndarray | Sequence | int | float): Data to
be converted.
"""
if isinstance(data, torch.Tensor):
return data
elif isinstance(data, np.ndarray):
return torch.from_numpy(data)
elif isinstance(data, int):
return torch.LongTensor([data])
elif isinstance(data, float):
return torch.FloatTensor([data])
else:
raise TypeError(f'type {type(data)} cannot be converted to tensor.')
@PROCESS.register_module
class ToTensor(object):
"""Convert some results to :obj:`torch.Tensor` by given keys.
Args:
keys (Sequence[str]): Keys that need to be converted to Tensor.
"""
def __init__(self, keys=['img', 'mask'], cfg=None):
self.keys = keys
def __call__(self, sample):
data = {}
if len(sample['img'].shape) < 3:
sample['img'] = np.expand_dims(img, -1)
for key in self.keys:
if key == 'img_metas' or key == 'gt_masks' or key == 'lane_line':
data[key] = sample[key]
continue
data[key] = to_tensor(sample[key])
data['img'] = data['img'].permute(2, 0, 1)
return data
def __repr__(self):
return self.__class__.__name__ + f'(keys={self.keys})'
@PROCESS.register_module
class RandomLROffsetLABEL(object):
def __init__(self, max_offset, cfg=None):
self.max_offset = max_offset
def __call__(self, sample):
img = sample['img']
label = sample['mask']
offset = np.random.randint(-self.max_offset, self.max_offset)
h, w = img.shape[:2]
img = np.array(img)
if offset > 0:
img[:, offset:, :] = img[:, 0:w - offset, :]
img[:, :offset, :] = 0
if offset < 0:
real_offset = -offset
img[:, 0:w - real_offset, :] = img[:, real_offset:, :]
img[:, w - real_offset:, :] = 0
label = np.array(label)
if offset > 0:
label[:, offset:] = label[:, 0:w - offset]
label[:, :offset] = 0
if offset < 0:
offset = -offset
label[:, 0:w - offset] = label[:, offset:]
label[:, w - offset:] = 0
sample['img'] = img
sample['mask'] = label
return sample
@PROCESS.register_module
class RandomUDoffsetLABEL(object):
def __init__(self, max_offset, cfg=None):
self.max_offset = max_offset
def __call__(self, sample):
img = sample['img']
label = sample['mask']
offset = np.random.randint(-self.max_offset, self.max_offset)
h, w = img.shape[:2]
img = np.array(img)
if offset > 0:
img[offset:, :, :] = img[0:h - offset, :, :]
img[:offset, :, :] = 0
if offset < 0:
real_offset = -offset
img[0:h - real_offset, :, :] = img[real_offset:, :, :]
img[h - real_offset:, :, :] = 0
label = np.array(label)
if offset > 0:
label[offset:, :] = label[0:h - offset, :]
label[:offset, :] = 0
if offset < 0:
offset = -offset
label[0:h - offset, :] = label[offset:, :]
label[h - offset:, :] = 0
sample['img'] = img
sample['mask'] = label
return sample
@PROCESS.register_module
class Resize(object):
def __init__(self, size, cfg=None):
assert (isinstance(size, collections.Iterable) and len(size) == 2)
self.size = size
def __call__(self, sample):
out = list()
sample['img'] = cv2.resize(sample['img'],
self.size,
interpolation=cv2.INTER_CUBIC)
if 'mask' in sample:
sample['mask'] = cv2.resize(sample['mask'],
self.size,
interpolation=cv2.INTER_NEAREST)
return sample
@PROCESS.register_module
class RandomCrop(object):
def __init__(self, size, cfg=None):
if isinstance(size, numbers.Number):
self.size = (int(size), int(size))
else:
self.size = size
def __call__(self, img_group):
h, w = img_group[0].shape[0:2]
th, tw = self.size
out_images = list()
h1 = random.randint(0, max(0, h - th))
w1 = random.randint(0, max(0, w - tw))
h2 = min(h1 + th, h)
w2 = min(w1 + tw, w)
for img in img_group:
assert (img.shape[0] == h and img.shape[1] == w)
out_images.append(img[h1:h2, w1:w2, ...])
return out_images
@PROCESS.register_module
class CenterCrop(object):
def __init__(self, size, cfg=None):
if isinstance(size, numbers.Number):
self.size = (int(size), int(size))
else:
self.size = size
def __call__(self, img_group):
h, w = img_group[0].shape[0:2]
th, tw = self.size
out_images = list()
h1 = max(0, int((h - th) / 2))
w1 = max(0, int((w - tw) / 2))
h2 = min(h1 + th, h)
w2 = min(w1 + tw, w)
for img in img_group:
assert (img.shape[0] == h and img.shape[1] == w)
out_images.append(img[h1:h2, w1:w2, ...])
return out_images
@PROCESS.register_module
class RandomRotation(object):
def __init__(self,
degree=(-10, 10),
interpolation=(cv2.INTER_LINEAR, cv2.INTER_NEAREST),
padding=None,
cfg=None):
self.degree = degree
self.interpolation = interpolation
self.padding = padding
if self.padding is None:
self.padding = [0, 0]
def _rotate_img(self, sample, map_matrix):
h, w = sample['img'].shape[0:2]
sample['img'] = cv2.warpAffine(sample['img'],
map_matrix, (w, h),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT,
borderValue=self.padding)
def _rotate_mask(self, sample, map_matrix):
if 'mask' not in sample:
return
h, w = sample['mask'].shape[0:2]
sample['mask'] = cv2.warpAffine(sample['mask'],
map_matrix, (w, h),
flags=cv2.INTER_NEAREST,
borderMode=cv2.BORDER_CONSTANT,
borderValue=self.padding)
def __call__(self, sample):
v = random.random()
if v < 0.5:
degree = random.uniform(self.degree[0], self.degree[1])
h, w = sample['img'].shape[0:2]
center = (w / 2, h / 2)
map_matrix = cv2.getRotationMatrix2D(center, degree, 1.0)
self._rotate_img(sample, map_matrix)
self._rotate_mask(sample, map_matrix)
return sample
@PROCESS.register_module
class RandomBlur(object):
def __init__(self, applied, cfg=None):
self.applied = applied
def __call__(self, img_group):
assert (len(self.applied) == len(img_group))
v = random.random()
if v < 0.5:
out_images = []
for img, a in zip(img_group, self.applied):
if a:
img = cv2.GaussianBlur(img, (5, 5),
random.uniform(1e-6, 0.6))
out_images.append(img)
if len(img.shape) > len(out_images[-1].shape):
out_images[-1] = out_images[-1][
..., np.newaxis] # single channel image
return out_images
else:
return img_group
@PROCESS.register_module
class RandomHorizontalFlip(object):
"""Randomly horizontally flips the given numpy Image with a probability of 0.5
"""
def __init__(self, cfg=None):
pass
def __call__(self, sample):
v = random.random()
if v < 0.5:
sample['img'] = np.fliplr(sample['img'])
if 'mask' in sample: sample['mask'] = np.fliplr(sample['mask'])
return sample
@PROCESS.register_module
class Normalize(object):
def __init__(self, img_norm, cfg=None):
self.mean = np.array(img_norm['mean'], dtype=np.float32)
self.std = np.array(img_norm['std'], dtype=np.float32)
def __call__(self, sample):
m = self.mean
s = self.std
img = sample['img']
if len(m) == 1:
img = img - np.array(m) # single channel image
img = img / np.array(s)
else:
img = img - np.array(m)[np.newaxis, np.newaxis, ...]
img = img / np.array(s)[np.newaxis, np.newaxis, ...]
sample['img'] = img
return sample
def CLRTransforms(img_h, img_w):
return [
dict(name='Resize',
parameters=dict(size=dict(height=img_h, width=img_w)),
p=1.0),
dict(name='HorizontalFlip', parameters=dict(p=1.0), p=0.5),
dict(name='Affine',
parameters=dict(translate_percent=dict(x=(-0.1, 0.1),
y=(-0.1, 0.1)),
rotate=(-10, 10),
scale=(0.8, 1.2)),
p=0.7),
dict(name='Resize',
parameters=dict(size=dict(height=img_h, width=img_w)),
p=1.0),
]