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,2 @@
from .config import Config
from .registry import Registry, build_from_cfg

View File

@@ -0,0 +1,67 @@
# Bilinear grid_sample using Pad + Gather (ONNX/RKNN friendly).
# Ref: https://zenn.dev/pinto0309/scraps/7d4032067d0160
# https://github.com/ibaiGorordo/CREStereo-Pytorch/.../nets/utils/utils.py
import torch
import torch.nn.functional as F
def bilinear_grid_sample(im, grid, align_corners=False):
"""Drop-in replacement for F.grid_sample(..., mode='bilinear', padding_zeros).
Args:
im: (N, C, H, W)
grid: (N, Hg, Wg, 2), x/y in [-1, 1] (same as grid_sample)
"""
n, c, h, w = im.shape
gn, gh, gw, _ = grid.shape
assert n == gn
x = grid[..., 0]
y = grid[..., 1]
if align_corners:
x = ((x + 1) / 2) * (w - 1)
y = ((y + 1) / 2) * (h - 1)
else:
x = ((x + 1) * w - 1) / 2
y = ((y + 1) * h - 1) / 2
x = x.reshape(n, -1)
y = y.reshape(n, -1)
x0 = torch.floor(x).long()
y0 = torch.floor(y).long()
x1 = x0 + 1
y1 = y0 + 1
wa = ((x1 - x) * (y1 - y)).unsqueeze(1)
wb = ((x1 - x) * (y - y0)).unsqueeze(1)
wc = ((x - x0) * (y1 - y)).unsqueeze(1)
wd = ((x - x0) * (y - y0)).unsqueeze(1)
im_padded = F.pad(im, pad=[1, 1, 1, 1], mode='constant', value=0)
padded_h = h + 2
padded_w = w + 2
x0, x1, y0, y1 = x0 + 1, x1 + 1, y0 + 1, y1 + 1
# Clip in float so ONNX exports valid Clip (ORT rejects int64 Clip bounds).
x0 = x0.float().clamp(0, padded_w - 1).long()
x1 = x1.float().clamp(0, padded_w - 1).long()
y0 = y0.float().clamp(0, padded_h - 1).long()
y1 = y1.float().clamp(0, padded_h - 1).long()
im_flat = im_padded.reshape(n, c, -1)
x0_y0 = (x0 + y0 * padded_w).unsqueeze(1).expand(-1, c, -1)
x0_y1 = (x0 + y1 * padded_w).unsqueeze(1).expand(-1, c, -1)
x1_y0 = (x1 + y0 * padded_w).unsqueeze(1).expand(-1, c, -1)
x1_y1 = (x1 + y1 * padded_w).unsqueeze(1).expand(-1, c, -1)
ia = torch.gather(im_flat, 2, x0_y0)
ib = torch.gather(im_flat, 2, x0_y1)
ic = torch.gather(im_flat, 2, x1_y0)
id_ = torch.gather(im_flat, 2, x1_y1)
out = ia * wa + ib * wb + ic * wc + id_ * wd
return out.reshape(n, c, gh, gw)

View File

@@ -0,0 +1,418 @@
# Copyright (c) Open-MMLab. All rights reserved.
import ast
import os.path as osp
import shutil
import sys
import tempfile
from argparse import Action, ArgumentParser
from collections import abc
from importlib import import_module
from addict import Dict
from yapf.yapflib.yapf_api import FormatCode
BASE_KEY = '_base_'
DELETE_KEY = '_delete_'
RESERVED_KEYS = ['filename', 'text', 'pretty_text']
def check_file_exist(filename, msg_tmpl='file "{}" does not exist'):
if not osp.isfile(filename):
raise FileNotFoundError(msg_tmpl.format(filename))
class ConfigDict(Dict):
def __missing__(self, name):
raise KeyError(name)
def __getattr__(self, name):
try:
value = super(ConfigDict, self).__getattr__(name)
except KeyError:
ex = AttributeError(f"'{self.__class__.__name__}' object has no "
f"attribute '{name}'")
except Exception as e:
ex = e
else:
return value
raise ex
def add_args(parser, cfg, prefix=''):
for k, v in cfg.items():
if isinstance(v, str):
parser.add_argument('--' + prefix + k)
elif isinstance(v, int):
parser.add_argument('--' + prefix + k, type=int)
elif isinstance(v, float):
parser.add_argument('--' + prefix + k, type=float)
elif isinstance(v, bool):
parser.add_argument('--' + prefix + k, action='store_true')
elif isinstance(v, dict):
add_args(parser, v, prefix + k + '.')
elif isinstance(v, abc.Iterable):
parser.add_argument('--' + prefix + k, type=type(v[0]), nargs='+')
else:
print(f'cannot parse key {prefix + k} of type {type(v)}')
return parser
class Config:
"""A facility for config and config files.
It supports common file formats as configs: python/json/yaml. The interface
is the same as a dict object and also allows access config values as
attributes.
Example:
>>> cfg = Config(dict(a=1, b=dict(b1=[0, 1])))
>>> cfg.a
1
>>> cfg.b
{'b1': [0, 1]}
>>> cfg.b.b1
[0, 1]
>>> cfg = Config.fromfile('tests/data/config/a.py')
>>> cfg.filename
"/home/kchen/projects/mmcv/tests/data/config/a.py"
>>> cfg.item4
'test'
>>> cfg
"Config [path: /home/kchen/projects/mmcv/tests/data/config/a.py]: "
"{'item1': [1, 2], 'item2': {'a': 0}, 'item3': True, 'item4': 'test'}"
"""
@staticmethod
def _validate_py_syntax(filename):
with open(filename) as f:
content = f.read()
try:
ast.parse(content)
except SyntaxError:
raise SyntaxError('There are syntax errors in config '
f'file {filename}')
@staticmethod
def _file2dict(filename):
filename = osp.abspath(osp.expanduser(filename))
check_file_exist(filename)
if filename.endswith('.py'):
with tempfile.TemporaryDirectory() as temp_config_dir:
temp_config_file = tempfile.NamedTemporaryFile(
dir=temp_config_dir, suffix='.py')
temp_config_name = osp.basename(temp_config_file.name)
shutil.copyfile(filename,
osp.join(temp_config_dir, temp_config_name))
temp_module_name = osp.splitext(temp_config_name)[0]
sys.path.insert(0, temp_config_dir)
Config._validate_py_syntax(filename)
mod = import_module(temp_module_name)
sys.path.pop(0)
cfg_dict = {
name: value
for name, value in mod.__dict__.items()
if not name.startswith('__')
}
# delete imported module
del sys.modules[temp_module_name]
# close temp file
temp_config_file.close()
elif filename.endswith(('.yml', '.yaml', '.json')):
import mmcv
cfg_dict = mmcv.load(filename)
else:
raise IOError('Only py/yml/yaml/json type are supported now!')
cfg_text = ''
with open(filename, 'r') as f:
cfg_text += f.read()
if BASE_KEY in cfg_dict:
cfg_dir = osp.dirname(filename)
base_filename = cfg_dict.pop(BASE_KEY)
base_filename = base_filename if isinstance(
base_filename, list) else [base_filename]
cfg_dict_list = list()
cfg_text_list = list()
for f in base_filename:
_cfg_dict, _cfg_text = Config._file2dict(osp.join(cfg_dir, f))
cfg_dict_list.append(_cfg_dict)
cfg_text_list.append(_cfg_text)
base_cfg_dict = dict()
for c in cfg_dict_list:
if len(base_cfg_dict.keys() & c.keys()) > 0:
raise KeyError('Duplicate key is not allowed among bases')
base_cfg_dict.update(c)
base_cfg_dict = Config._merge_a_into_b(cfg_dict, base_cfg_dict)
cfg_dict = base_cfg_dict
# merge cfg_text
cfg_text_list.append(cfg_text)
cfg_text = '\n'.join(cfg_text_list)
return cfg_dict, cfg_text
@staticmethod
def _merge_a_into_b(a, b):
# merge dict `a` into dict `b` (non-inplace). values in `a` will
# overwrite `b`.
# copy first to avoid inplace modification
b = b.copy()
for k, v in a.items():
if isinstance(v, dict) and k in b and not v.pop(DELETE_KEY, False):
if not isinstance(b[k], dict):
raise TypeError(
f'{k}={v} in child config cannot inherit from base '
f'because {k} is a dict in the child config but is of '
f'type {type(b[k])} in base config. You may set '
f'`{DELETE_KEY}=True` to ignore the base config')
b[k] = Config._merge_a_into_b(v, b[k])
else:
b[k] = v
return b
@staticmethod
def fromfile(filename):
cfg_dict, cfg_text = Config._file2dict(filename)
return Config(cfg_dict, cfg_text=cfg_text, filename=filename)
@staticmethod
def auto_argparser(description=None):
"""Generate argparser from config file automatically (experimental)
"""
partial_parser = ArgumentParser(description=description)
partial_parser.add_argument('config', help='config file path')
cfg_file = partial_parser.parse_known_args()[0].config
cfg = Config.fromfile(cfg_file)
parser = ArgumentParser(description=description)
parser.add_argument('config', help='config file path')
add_args(parser, cfg)
return parser, cfg
def __init__(self, cfg_dict=None, cfg_text=None, filename=None):
if cfg_dict is None:
cfg_dict = dict()
elif not isinstance(cfg_dict, dict):
raise TypeError('cfg_dict must be a dict, but '
f'got {type(cfg_dict)}')
for key in cfg_dict:
if key in RESERVED_KEYS:
raise KeyError(f'{key} is reserved for config file')
super(Config, self).__setattr__('_cfg_dict', ConfigDict(cfg_dict))
super(Config, self).__setattr__('_filename', filename)
if cfg_text:
text = cfg_text
elif filename:
with open(filename, 'r') as f:
text = f.read()
else:
text = ''
super(Config, self).__setattr__('_text', text)
@property
def filename(self):
return self._filename
@property
def text(self):
return self._text
@property
def pretty_text(self):
indent = 4
def _indent(s_, num_spaces):
s = s_.split('\n')
if len(s) == 1:
return s_
first = s.pop(0)
s = [(num_spaces * ' ') + line for line in s]
s = '\n'.join(s)
s = first + '\n' + s
return s
def _format_basic_types(k, v, use_mapping=False):
if isinstance(v, str):
v_str = f"'{v}'"
else:
v_str = str(v)
if use_mapping:
k_str = f"'{k}'" if isinstance(k, str) else str(k)
attr_str = f'{k_str}: {v_str}'
else:
attr_str = f'{str(k)}={v_str}'
attr_str = _indent(attr_str, indent)
return attr_str
def _format_list(k, v, use_mapping=False):
# check if all items in the list are dict
if all(isinstance(_, dict) for _ in v):
v_str = '[\n'
v_str += '\n'.join(
f'dict({_indent(_format_dict(v_), indent)}),'
for v_ in v).rstrip(',')
if use_mapping:
k_str = f"'{k}'" if isinstance(k, str) else str(k)
attr_str = f'{k_str}: {v_str}'
else:
attr_str = f'{str(k)}={v_str}'
attr_str = _indent(attr_str, indent) + ']'
else:
attr_str = _format_basic_types(k, v, use_mapping)
return attr_str
def _contain_invalid_identifier(dict_str):
contain_invalid_identifier = False
for key_name in dict_str:
contain_invalid_identifier |= \
(not str(key_name).isidentifier())
return contain_invalid_identifier
def _format_dict(input_dict, outest_level=False):
r = ''
s = []
use_mapping = _contain_invalid_identifier(input_dict)
if use_mapping:
r += '{'
for idx, (k, v) in enumerate(input_dict.items()):
is_last = idx >= len(input_dict) - 1
end = '' if outest_level or is_last else ','
if isinstance(v, dict):
v_str = '\n' + _format_dict(v)
if use_mapping:
k_str = f"'{k}'" if isinstance(k, str) else str(k)
attr_str = f'{k_str}: dict({v_str}'
else:
attr_str = f'{str(k)}=dict({v_str}'
attr_str = _indent(attr_str, indent) + ')' + end
elif isinstance(v, list):
attr_str = _format_list(k, v, use_mapping) + end
else:
attr_str = _format_basic_types(k, v, use_mapping) + end
s.append(attr_str)
r += '\n'.join(s)
if use_mapping:
r += '}'
return r
cfg_dict = self._cfg_dict.to_dict()
text = _format_dict(cfg_dict, outest_level=True)
# copied from setup.cfg
yapf_style = dict(based_on_style='pep8',
blank_line_before_nested_class_or_def=True,
split_before_expression_after_opening_paren=True)
text, _ = FormatCode(text, style_config=yapf_style, verify=True)
return text
def __repr__(self):
return f'Config (path: {self.filename}): {self._cfg_dict.__repr__()}'
def __len__(self):
return len(self._cfg_dict)
def __getattr__(self, name):
return getattr(self._cfg_dict, name)
def __getitem__(self, name):
return self._cfg_dict.__getitem__(name)
def __setattr__(self, name, value):
if isinstance(value, dict):
value = ConfigDict(value)
self._cfg_dict.__setattr__(name, value)
def __setitem__(self, name, value):
if isinstance(value, dict):
value = ConfigDict(value)
self._cfg_dict.__setitem__(name, value)
def __iter__(self):
return iter(self._cfg_dict)
def haskey(self, name):
return hasattr(self._cfg_dict, name)
def dump(self, file=None):
cfg_dict = super(Config, self).__getattribute__('_cfg_dict').to_dict()
if self.filename.endswith('.py'):
if file is None:
return self.pretty_text
else:
with open(file, 'w') as f:
f.write(self.pretty_text)
else:
import mmcv
if file is None:
file_format = self.filename.split('.')[-1]
return mmcv.dump(cfg_dict, file_format=file_format)
else:
mmcv.dump(cfg_dict, file)
def has_attr_in_cfg(self, name):
return hasattr(self._cfg_dict, name)
def merge_from_dict(self, options):
"""Merge list into cfg_dict
Merge the dict parsed by MultipleKVAction into this cfg.
Examples:
>>> options = {'model.backbone.depth': 50,
... 'model.backbone.with_cp':True}
>>> cfg = Config(dict(model=dict(backbone=dict(type='ResNet'))))
>>> cfg.merge_from_dict(options)
>>> cfg_dict = super(Config, self).__getattribute__('_cfg_dict')
>>> assert cfg_dict == dict(
... model=dict(backbone=dict(depth=50, with_cp=True)))
Args:
options (dict): dict of configs to merge from.
"""
option_cfg_dict = {}
for full_key, v in options.items():
d = option_cfg_dict
key_list = full_key.split('.')
for subkey in key_list[:-1]:
d.setdefault(subkey, ConfigDict())
d = d[subkey]
subkey = key_list[-1]
d[subkey] = v
cfg_dict = super(Config, self).__getattribute__('_cfg_dict')
super(Config, self).__setattr__(
'_cfg_dict', Config._merge_a_into_b(option_cfg_dict, cfg_dict))
class DictAction(Action):
"""
argparse action to split an argument into KEY=VALUE form
on the first = and append to a dictionary. List options should
be passed as comma separated values, i.e KEY=V1,V2,V3
"""
@staticmethod
def _parse_int_float_bool(val):
try:
return int(val)
except ValueError:
pass
try:
return float(val)
except ValueError:
pass
if val.lower() in ['true', 'false']:
return True if val.lower() == 'true' else False
return val
def __call__(self, parser, namespace, values, option_string=None):
options = {}
for kv in values:
key, val = kv.split('=', maxsplit=1)
val = [self._parse_int_float_bool(v) for v in val.split(',')]
if len(val) == 1:
val = val[0]
options[key] = val
setattr(namespace, self.dest, options)

View File

@@ -0,0 +1,257 @@
import os
import argparse
from functools import partial
import cv2
import numpy as np
from tqdm import tqdm
from p_tqdm import t_map, p_map
from scipy.interpolate import splprep, splev
from scipy.optimize import linear_sum_assignment
from shapely.geometry import LineString, Polygon
def draw_lane(lane, img=None, img_shape=None, width=30):
if img is None:
img = np.zeros(img_shape, dtype=np.uint8)
lane = lane.astype(np.int32)
for p1, p2 in zip(lane[:-1], lane[1:]):
cv2.line(img,
tuple(p1),
tuple(p2),
color=(255, 255, 255),
thickness=width)
return img
def discrete_cross_iou(xs, ys, width=30, img_shape=(590, 1640, 3)):
xs = [draw_lane(lane, img_shape=img_shape, width=width) > 0 for lane in xs]
ys = [draw_lane(lane, img_shape=img_shape, width=width) > 0 for lane in ys]
ious = np.zeros((len(xs), len(ys)))
for i, x in enumerate(xs):
for j, y in enumerate(ys):
ious[i, j] = (x & y).sum() / (x | y).sum()
return ious
def continuous_cross_iou(xs, ys, width=30, img_shape=(590, 1640, 3)):
h, w, _ = img_shape
image = Polygon([(0, 0), (0, h - 1), (w - 1, h - 1), (w - 1, 0)])
xs = [
LineString(lane).buffer(distance=width / 2., cap_style=1,
join_style=2).intersection(image)
for lane in xs
]
ys = [
LineString(lane).buffer(distance=width / 2., cap_style=1,
join_style=2).intersection(image)
for lane in ys
]
ious = np.zeros((len(xs), len(ys)))
for i, x in enumerate(xs):
for j, y in enumerate(ys):
ious[i, j] = x.intersection(y).area / x.union(y).area
return ious
def interp(points, n=50):
x = [x for x, _ in points]
y = [y for _, y in points]
tck, u = splprep([x, y], s=0, t=n, k=min(3, len(points) - 1))
u = np.linspace(0., 1., num=(len(u) - 1) * n + 1)
return np.array(splev(u, tck)).T
def culane_metric(pred,
anno,
width=30,
iou_thresholds=[0.5],
official=True,
img_shape=(590, 1640, 3)):
_metric = {}
for thr in iou_thresholds:
tp = 0
fp = 0 if len(anno) != 0 else len(pred)
fn = 0 if len(pred) != 0 else len(anno)
_metric[thr] = [tp, fp, fn]
interp_pred = np.array([interp(pred_lane, n=5) for pred_lane in pred],
dtype=object) # (4, 50, 2)
interp_anno = np.array([interp(anno_lane, n=5) for anno_lane in anno],
dtype=object) # (4, 50, 2)
if official:
ious = discrete_cross_iou(interp_pred,
interp_anno,
width=width,
img_shape=img_shape)
else:
ious = continuous_cross_iou(interp_pred,
interp_anno,
width=width,
img_shape=img_shape)
row_ind, col_ind = linear_sum_assignment(1 - ious)
_metric = {}
for thr in iou_thresholds:
tp = int((ious[row_ind, col_ind] > thr).sum())
fp = len(pred) - tp
fn = len(anno) - tp
_metric[thr] = [tp, fp, fn]
return _metric
def load_culane_img_data(path):
with open(path, 'r') as data_file:
img_data = data_file.readlines()
img_data = [line.split() for line in img_data]
img_data = [list(map(float, lane)) for lane in img_data]
img_data = [[(lane[i], lane[i + 1]) for i in range(0, len(lane), 2)]
for lane in img_data]
img_data = [lane for lane in img_data if len(lane) >= 2]
return img_data
def load_culane_data(data_dir, file_list_path):
with open(file_list_path, 'r') as file_list:
filepaths = [
os.path.join(
data_dir, line[1 if line[0] == '/' else 0:].rstrip().replace(
'.jpg', '.lines.txt')) for line in file_list.readlines()
]
data = []
for path in filepaths:
img_data = load_culane_img_data(path)
data.append(img_data)
return data
def eval_predictions(pred_dir,
anno_dir,
list_path,
iou_thresholds=[0.5],
width=30,
official=True,
sequential=False):
import logging
logger = logging.getLogger(__name__)
logger.info('Calculating metric for List: {}'.format(list_path))
predictions = load_culane_data(pred_dir, list_path)
annotations = load_culane_data(anno_dir, list_path)
img_shape = (590, 1640, 3)
if sequential:
results = map(
partial(culane_metric,
width=width,
official=official,
iou_thresholds=iou_thresholds,
img_shape=img_shape), predictions, annotations)
else:
from multiprocessing import Pool, cpu_count
from itertools import repeat
with Pool(cpu_count()) as p:
results = p.starmap(culane_metric, zip(predictions, annotations,
repeat(width),
repeat(iou_thresholds),
repeat(official),
repeat(img_shape)))
mean_f1, mean_prec, mean_recall, total_tp, total_fp, total_fn = 0, 0, 0, 0, 0, 0
ret = {}
for thr in iou_thresholds:
tp = sum(m[thr][0] for m in results)
fp = sum(m[thr][1] for m in results)
fn = sum(m[thr][2] for m in results)
precision = float(tp) / (tp + fp) if tp != 0 else 0
recall = float(tp) / (tp + fn) if tp != 0 else 0
f1 = 2 * precision * recall / (precision + recall) if tp !=0 else 0
logger.info('iou thr: {:.2f}, tp: {}, fp: {}, fn: {},'
'precision: {}, recall: {}, f1: {}'.format(
thr, tp, fp, fn, precision, recall, f1))
mean_f1 += f1 / len(iou_thresholds)
mean_prec += precision / len(iou_thresholds)
mean_recall += recall / len(iou_thresholds)
total_tp += tp
total_fp += fp
total_fn += fn
ret[thr] = {
'TP': tp,
'FP': fp,
'FN': fn,
'Precision': precision,
'Recall': recall,
'F1': f1
}
if len(iou_thresholds) > 2:
logger.info('mean result, total_tp: {}, total_fp: {}, total_fn: {},'
'precision: {}, recall: {}, f1: {}'.format(total_tp, total_fp,
total_fn, mean_prec, mean_recall, mean_f1))
ret['mean'] = {
'TP': total_tp,
'FP': total_fp,
'FN': total_fn,
'Precision': mean_prec,
'Recall': mean_recall,
'F1': mean_f1
}
return ret
def main():
args = parse_args()
for list_path in args.list:
results = eval_predictions(args.pred_dir,
args.anno_dir,
list_path,
width=args.width,
official=args.official,
sequential=args.sequential)
header = '=' * 20 + ' Results ({})'.format(
os.path.basename(list_path)) + '=' * 20
print(header)
for metric, value in results.items():
if isinstance(value, float):
print('{}: {:.4f}'.format(metric, value))
else:
print('{}: {}'.format(metric, value))
print('=' * len(header))
def parse_args():
parser = argparse.ArgumentParser(description="Measure CULane's metric")
parser.add_argument(
"--pred_dir",
help="Path to directory containing the predicted lanes",
required=True)
parser.add_argument(
"--anno_dir",
help="Path to directory containing the annotated lanes",
required=True)
parser.add_argument("--width",
type=int,
default=30,
help="Width of the lane")
parser.add_argument("--list",
nargs='+',
help="Path to txt file containing the list of files",
required=True)
parser.add_argument("--sequential",
action='store_true',
help="Run sequentially instead of in parallel")
parser.add_argument("--official",
action='store_true',
help="Use official way to calculate the metric")
return parser.parse_args()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,90 @@
"""Merge train_packs for CLRNet (same layout as UFLD lane0_copy)."""
from __future__ import annotations
import json
from pathlib import Path
def parse_gt_line(line: str) -> tuple[str, str] | None:
parts = line.strip().split()
if len(parts) < 2:
return None
return parts[0].lstrip("/"), parts[1].lstrip("/")
def load_registry(data_root: Path) -> dict:
p = data_root / "datasets_registry.json"
if p.is_file():
return json.loads(p.read_text(encoding="utf-8"))
return {}
def resolve_pack_dir(pack: str, data_root: Path, registry: dict) -> str:
aliases = registry.get("aliases", {})
pack_dirs = registry.get("pack_dirs", {})
if pack in aliases:
pack = aliases[pack]
if pack in pack_dirs:
pack = pack_dirs[pack]
if not (data_root / pack).is_dir():
raise FileNotFoundError(f"pack not found: {data_root / pack} ({pack!r})")
return pack
def apply_pack_prefix(img: str, msk: str, prefix: str) -> tuple[str, str]:
if not prefix:
return img, msk
if not img.startswith(prefix):
img = prefix + img
if not msk.startswith(prefix):
msk = prefix + msk
return img, msk
def resolve_list_file(cfg, split: str = "train") -> str | None:
"""Return list path relative to cfg.dataset_path, or None to use defaults."""
packs_key = "train_packs" if split == "train" else "val_packs"
packs = getattr(cfg, packs_key, None)
if not packs:
return getattr(cfg, f"{split}_list_file", None)
if isinstance(packs, str):
packs = [p.strip() for p in packs.split(",") if p.strip()]
else:
packs = list(packs)
data_root = Path(cfg.dataset_path).resolve()
registry = load_registry(data_root)
pack_dirs = [resolve_pack_dir(p, data_root, registry) for p in packs]
list_name = getattr(cfg, "pack_list_name", "list/train_gt.txt")
if split == "val":
list_name = getattr(cfg, "pack_val_list_name", "list/val_gt.txt")
merged_dir = Path(getattr(cfg, "merged_list_dir", "lists_merged"))
safe = "__".join(pack_dirs)
out_name = getattr(cfg, f"merged_{split}_list", None) or f"{split}__{safe}.txt"
out_path = data_root / merged_dir / out_name
out_rel = (merged_dir / out_name).as_posix()
if getattr(cfg, "remerge_lists", False) or not out_path.is_file():
merged = []
seen = set()
for pack_dir in pack_dirs:
prefix = f"{pack_dir}/"
list_path = data_root / pack_dir / list_name
if not list_path.is_file():
raise FileNotFoundError(list_path)
for line in list_path.read_text(encoding="utf-8", errors="replace").splitlines():
p = parse_gt_line(line)
if not p:
continue
img, msk = apply_pack_prefix(p[0], p[1], prefix)
if img in seen:
continue
seen.add(img)
merged.append(f"{img} {msk}")
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text("\n".join(merged) + "\n", encoding="utf-8")
return out_rel

View File

@@ -0,0 +1,50 @@
from scipy.interpolate import InterpolatedUnivariateSpline
import numpy as np
class Lane:
def __init__(self, points=None, invalid_value=-2., metadata=None):
super(Lane, self).__init__()
self.curr_iter = 0
self.points = points
self.invalid_value = invalid_value
self.function = InterpolatedUnivariateSpline(points[:, 1],
points[:, 0],
k=min(3,
len(points) - 1))
self.min_y = points[:, 1].min() - 0.01
self.max_y = points[:, 1].max() + 0.01
self.metadata = metadata or {}
def __repr__(self):
return '[Lane]\n' + str(self.points) + '\n[/Lane]'
def __call__(self, lane_ys):
lane_xs = self.function(lane_ys)
lane_xs[(lane_ys < self.min_y) |
(lane_ys > self.max_y)] = self.invalid_value
return lane_xs
def to_array(self, cfg):
sample_y = cfg.sample_y
img_w, img_h = cfg.ori_img_w, cfg.ori_img_h
ys = np.array(sample_y) / float(img_h)
xs = self(ys)
valid_mask = (xs >= 0) & (xs < 1)
lane_xs = xs[valid_mask] * img_w
lane_ys = ys[valid_mask] * img_h
lane = np.concatenate((lane_xs.reshape(-1, 1), lane_ys.reshape(-1, 1)),
axis=1)
return lane
def __iter__(self):
return self
def __next__(self):
if self.curr_iter < len(self.points):
self.curr_iter += 1
return self.points[self.curr_iter - 1]
self.curr_iter = 0
raise StopIteration

View File

@@ -0,0 +1,315 @@
""" Evaluation script for the CULane metric on the LLAMAS dataset.
This script will compute the F1, precision and recall metrics as described in the CULane benchmark.
The predictions format is the same one used in the CULane benchmark.
In summary, for every annotation file:
labels/a/b/c.json
There should be a prediction file:
predictions/a/b/c.lines.txt
Inside each .lines.txt file each line will contain a sequence of points (x, y) separated by spaces.
For more information, please see https://xingangpan.github.io/projects/CULane.html
This script uses two methods to compute the IoU: one using an image to draw the lanes (named `discrete` here) and
another one that uses shapes with the shapely library (named `continuous` here). The results achieved with the first
method are very close to the official CULane implementation. Although the second should be a more exact method and is
faster to compute, it deviates more from the official implementation. By default, the method closer to the official
metric is used.
"""
import os
import argparse
from functools import partial
import cv2
import numpy as np
from p_tqdm import t_map, p_map
from scipy.interpolate import splprep, splev
from scipy.optimize import linear_sum_assignment
from shapely.geometry import LineString, Polygon
import clrnet.utils.llamas_utils as llamas_utils
LLAMAS_IMG_RES = (717, 1276)
def add_ys(xs):
"""For each x in xs, make a tuple with x and its corresponding y."""
xs = np.array(xs[300:])
valid = xs >= 0
xs = xs[valid]
assert len(xs) > 1
ys = np.arange(300, 717)[valid]
return list(zip(xs, ys))
def draw_lane(lane, img=None, img_shape=None, width=30):
"""Draw a lane (a list of points) on an image by drawing a line with width `width` through each
pair of points i and i+i"""
if img is None:
img = np.zeros(img_shape, dtype=np.uint8)
lane = lane.astype(np.int32)
for p1, p2 in zip(lane[:-1], lane[1:]):
cv2.line(img, tuple(p1), tuple(p2), color=(1, ), thickness=width)
return img
def discrete_cross_iou(xs, ys, width=30, img_shape=LLAMAS_IMG_RES):
"""For each lane in xs, compute its Intersection Over Union (IoU) with each lane in ys by drawing the lanes on
an image"""
xs = [draw_lane(lane, img_shape=img_shape, width=width) > 0 for lane in xs]
ys = [draw_lane(lane, img_shape=img_shape, width=width) > 0 for lane in ys]
ious = np.zeros((len(xs), len(ys)))
for i, x in enumerate(xs):
for j, y in enumerate(ys):
# IoU by the definition: sum all intersections (binary and) and divide by the sum of the union (binary or)
ious[i, j] = (x & y).sum() / (x | y).sum()
return ious
def continuous_cross_iou(xs, ys, width=30, img_shape=LLAMAS_IMG_RES):
"""For each lane in xs, compute its Intersection Over Union (IoU) with each lane in ys using the area between each
pair of points"""
h, w = img_shape
image = Polygon([(0, 0), (0, h - 1), (w - 1, h - 1), (w - 1, 0)])
xs = [
LineString(lane).buffer(distance=width / 2., cap_style=1,
join_style=2).intersection(image)
for lane in xs
]
ys = [
LineString(lane).buffer(distance=width / 2., cap_style=1,
join_style=2).intersection(image)
for lane in ys
]
ious = np.zeros((len(xs), len(ys)))
for i, x in enumerate(xs):
for j, y in enumerate(ys):
ious[i, j] = x.intersection(y).area / x.union(y).area
return ious
def interpolate_lane(points, n=50):
"""Spline interpolation of a lane. Used on the predictions"""
x = [x for x, _ in points]
y = [y for _, y in points]
tck, _ = splprep([x, y], s=0, t=n, k=min(3, len(points) - 1))
u = np.linspace(0., 1., n)
return np.array(splev(u, tck)).T
def culane_metric(pred,
anno,
width=30,
iou_thresholds=[0.5],
unofficial=False,
img_shape=LLAMAS_IMG_RES):
_metric = {}
for thr in iou_thresholds:
tp = 0
fp = 0 if len(anno) != 0 else len(pred)
fn = 0 if len(pred) != 0 else len(anno)
_metric[thr] = [tp, fp, fn]
"""Computes CULane's metric for a single image"""
if len(pred) == 0:
return 0, 0, len(anno), _metric
if len(anno) == 0:
return 0, len(pred), 0, _metric
interp_pred = np.array([
interpolate_lane(pred_lane, n=50) for pred_lane in pred
]) # (4, 50, 2)
anno = np.array([np.array(anno_lane) for anno_lane in anno], dtype=object)
if unofficial:
ious = continuous_cross_iou(interp_pred, anno, width=width)
else:
ious = discrete_cross_iou(interp_pred,
anno,
width=width,
img_shape=img_shape)
row_ind, col_ind = linear_sum_assignment(1 - ious)
_metric = {}
for thr in iou_thresholds:
tp = int((ious[row_ind, col_ind] > thr).sum())
fp = len(pred) - tp
fn = len(anno) - tp
_metric[thr] = [tp, fp, fn]
return _metric
def load_prediction(path):
"""Loads an image's predictions
Returns a list of lanes, where each lane is a list of points (x,y)
"""
with open(path, 'r') as data_file:
img_data = data_file.readlines()
img_data = [line.split() for line in img_data]
img_data = [list(map(float, lane)) for lane in img_data]
img_data = [[(lane[i], lane[i + 1]) for i in range(0, len(lane), 2)]
for lane in img_data]
img_data = [lane for lane in img_data if len(lane) >= 2]
return img_data
def load_prediction_list(label_paths, pred_dir):
return [
load_prediction(
os.path.join(pred_dir, path.replace('.json', '.lines.txt')))
for path in label_paths
]
def load_labels(label_dir):
"""Loads the annotations and its paths
Each annotation is converted to a list of points (x, y)
"""
label_paths = llamas_utils.get_files_from_folder(label_dir, '.json')
annos = [
[
add_ys(xs) for xs in
llamas_utils.get_horizontal_values_for_four_lanes(label_path)
if (np.array(xs) >= 0).sum() > 1
] # lanes annotated with a single point are ignored
for label_path in label_paths
]
label_paths = [llamas_utils.get_label_base(p) for p in label_paths]
return np.array(annos, dtype=object), np.array(label_paths, dtype=object)
def eval_predictions(pred_dir,
anno_dir,
width=30,
iou_thresholds=[0.5],
unofficial=True,
sequential=False):
"""Evaluates the predictions in pred_dir and returns CULane's metrics (precision, recall, F1 and its components)"""
print(f'Loading annotation data ({anno_dir})...')
os.makedirs('cache', exist_ok=True)
annotations_path = 'cache/llamas_annotations.pkl'
label_path = 'cache/llamas_label_paths.pkl'
import pickle as pkl
if os.path.exists(annotations_path) and os.path.exists(label_path):
with open(annotations_path, 'rb') as cache_file:
annotations = pkl.load(cache_file)
with open(label_path, 'rb') as cache_file:
label_paths = pkl.load(cache_file)
else:
annotations, label_paths = load_labels(anno_dir)
with open(annotations_path, 'wb') as cache_file:
pkl.dump(annotations, cache_file)
with open(label_path, 'wb') as cache_file:
pkl.dump(label_paths, cache_file)
print(f'Loading prediction data ({pred_dir})...')
predictions = load_prediction_list(label_paths, pred_dir)
print('Calculating metric {}...'.format(
'sequentially' if sequential else 'in parallel'))
if sequential:
results = map(
partial(culane_metric,
width=width,
unofficial=unofficial,
img_shape=LLAMAS_IMG_RES), predictions, annotations)
else:
from multiprocessing import Pool, cpu_count
from itertools import repeat
with Pool(cpu_count()) as p:
results = p.starmap(culane_metric, zip(predictions, annotations,
repeat(width),
repeat(iou_thresholds),
repeat(unofficial),
repeat(LLAMAS_IMG_RES)))
import logging
logger = logging.getLogger(__name__)
mean_f1, mean_prec, mean_recall, total_tp, total_fp, total_fn = 0, 0, 0, 0, 0, 0
ret = {}
for thr in iou_thresholds:
tp = sum(m[thr][0] for m in results)
fp = sum(m[thr][1] for m in results)
fn = sum(m[thr][2] for m in results)
precision = float(tp) / (tp + fp) if tp != 0 else 0
recall = float(tp) / (tp + fn) if tp != 0 else 0
f1 = 2 * precision * recall / (precision + recall) if tp !=0 else 0
logger.info('iou thr: {:.2f}, tp: {}, fp: {}, fn: {}, '
'precision: {}, recall: {}, f1: {}'.format(
thr, tp, fp, fn, precision, recall, f1))
mean_f1 += f1 / len(iou_thresholds)
mean_prec += precision / len(iou_thresholds)
mean_recall += recall / len(iou_thresholds)
total_tp += tp
total_fp += fp
total_fn += fn
ret[thr] = {
'TP': tp,
'FP': fp,
'FN': fn,
'Precision': precision,
'Recall': recall,
'F1': f1
}
if len(iou_thresholds) > 2:
logger.info('mean result, total_tp: {}, total_fp: {}, total_fn: {},'
'precision: {}, recall: {}, f1: {}'.format(total_tp, total_fp,
total_fn, mean_prec, mean_recall, mean_f1))
ret['mean'] = {
'TP': total_tp,
'FP': total_fp,
'FN': total_fn,
'Precision': mean_prec,
'Recall': mean_recall,
'F1': mean_f1
}
return ret
def parse_args():
parser = argparse.ArgumentParser(
description="Measure CULane's metric on the LLAMAS dataset")
parser.add_argument(
"--pred_dir",
help="Path to directory containing the predicted lanes",
required=True)
parser.add_argument(
"--anno_dir",
help="Path to directory containing the annotated lanes",
required=True)
parser.add_argument("--width",
type=int,
default=30,
help="Width of the lane")
parser.add_argument("--sequential",
action='store_true',
help="Run sequentially instead of in parallel")
parser.add_argument("--unofficial",
action='store_true',
help="Use a faster but unofficial algorithm")
return parser.parse_args()
def main():
args = parse_args()
results = eval_predictions(args.pred_dir,
args.anno_dir,
width=args.width,
iou_thresholds=[0.5],
unofficial=args.unofficial,
sequential=args.sequential)
header = '=' * 20 + ' Results' + '=' * 20
print(header)
for metric, value in results.items():
if isinstance(value, float):
print('{}: {:.4f}'.format(metric, value))
else:
print('{}: {}'.format(metric, value))
print('=' * len(header))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,389 @@
# All following lines (which were slightly modified) were taken from: https://github.com/karstenBehrendt/unsupervised_llamas
# Its license is copied here
# ##### Begin License ######
# MIT License
# Copyright (c) 2019 Karsten Behrendt, Robert Bosch LLC
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# ##### End License ######
# Start code under the previous license
import os
import json
import numpy as np
def get_files_from_folder(directory, extension=None):
"""Get all files within a folder that fit the extension """
# NOTE Can be replaced by glob for newer python versions
label_files = []
for root, _, files in os.walk(directory):
for some_file in files:
label_files.append(os.path.abspath(os.path.join(root, some_file)))
if extension is not None:
label_files = list(filter(lambda x: x.endswith(extension),
label_files))
return label_files
def get_label_base(label_path):
""" Gets directory independent label path """
return '/'.join(label_path.split('/')[-2:])
def get_labels(dataset_root, split='test'):
""" Gets label files of specified dataset split """
label_paths = get_files_from_folder(os.path.join(dataset_root, split),
'.json')
return label_paths
def _extend_lane(lane, projection_matrix):
"""Extends marker closest to the camera
Adds an extra marker that reaches the end of the image
Parameters
----------
lane : iterable of markers
projection_matrix : 3x3 projection matrix
"""
# Unfortunately, we did not store markers beyond the image plane. That hurts us now
# z is the orthongal distance to the car. It's good enough
# The markers are automatically detected, mapped, and labeled. There exist faulty ones,
# e.g., horizontal markers which need to be filtered
filtered_markers = filter(
lambda x: (x['pixel_start']['y'] != x['pixel_end']['y'] and x[
'pixel_start']['x'] != x['pixel_end']['x']), lane['markers'])
# might be the first marker in the list but not guaranteed
closest_marker = min(filtered_markers, key=lambda x: x['world_start']['z'])
if closest_marker['world_start'][
'z'] < 0: # This one likely equals "if False"
return lane
# World marker extension approximation
x_gradient = (closest_marker['world_end']['x'] - closest_marker['world_start']['x']) /\
(closest_marker['world_end']['z'] - closest_marker['world_start']['z'])
y_gradient = (closest_marker['world_end']['y'] - closest_marker['world_start']['y']) /\
(closest_marker['world_end']['z'] - closest_marker['world_start']['z'])
zero_x = closest_marker['world_start']['x'] - (
closest_marker['world_start']['z'] - 1) * x_gradient
zero_y = closest_marker['world_start']['y'] - (
closest_marker['world_start']['z'] - 1) * y_gradient
# Pixel marker extension approximation
pixel_x_gradient = (closest_marker['pixel_end']['x'] - closest_marker['pixel_start']['x']) /\
(closest_marker['pixel_end']['y'] - closest_marker['pixel_start']['y'])
pixel_y_gradient = (closest_marker['pixel_end']['y'] - closest_marker['pixel_start']['y']) /\
(closest_marker['pixel_end']['x'] - closest_marker['pixel_start']['x'])
pixel_zero_x = closest_marker['pixel_start']['x'] + (
716 - closest_marker['pixel_start']['y']) * pixel_x_gradient
if pixel_zero_x < 0:
left_y = closest_marker['pixel_start'][
'y'] - closest_marker['pixel_start']['x'] * pixel_y_gradient
new_pixel_point = (0, left_y)
elif pixel_zero_x > 1276:
right_y = closest_marker['pixel_start']['y'] + (
1276 - closest_marker['pixel_start']['x']) * pixel_y_gradient
new_pixel_point = (1276, right_y)
else:
new_pixel_point = (pixel_zero_x, 716)
new_marker = {
'lane_marker_id': 'FAKE',
'world_end': {
'x': closest_marker['world_start']['x'],
'y': closest_marker['world_start']['y'],
'z': closest_marker['world_start']['z']
},
'world_start': {
'x': zero_x,
'y': zero_y,
'z': 1
},
'pixel_end': {
'x': closest_marker['pixel_start']['x'],
'y': closest_marker['pixel_start']['y']
},
'pixel_start': {
'x': ir(new_pixel_point[0]),
'y': ir(new_pixel_point[1])
}
}
lane['markers'].insert(0, new_marker)
return lane
class SplineCreator():
"""
For each lane divder
- all lines are projected
- linearly interpolated to limit oscillations
- interpolated by a spline
- subsampled to receive individual pixel values
The spline creation can be optimized!
- Better spline parameters
- Extend lowest marker to reach bottom of image would also help
- Extending last marker may in some cases be interesting too
Any help is welcome.
Call create_all_points and get the points in self.sampled_points
It has an x coordinate for each value for each lane
"""
def __init__(self, json_path):
self.json_path = json_path
self.json_content = read_json(json_path)
self.lanes = self.json_content['lanes']
self.lane_marker_points = {}
self.sampled_points = {} # <--- the interesting part
self.debug_image = np.zeros((717, 1276, 3), dtype=np.uint8)
def _sample_points(self, lane, ypp=5, between_markers=True):
""" Markers are given by start and endpoint. This one adds extra points
which need to be considered for the interpolation. Otherwise the spline
could arbitrarily oscillate between start and end of the individual markers
Parameters
----------
lane: polyline, in theory but there are artifacts which lead to inconsistencies
in ordering. There may be parallel lines. The lines may be dashed. It's messy.
ypp: y-pixels per point, e.g. 10 leads to a point every ten pixels
between_markers : bool, interpolates inbetween dashes
Notes
-----
Especially, adding points in the lower parts of the image (high y-values) because
the start and end points are too sparse.
Removing upper lane markers that have starting and end points mapped into the same pixel.
"""
# Collect all x values from all markers along a given line. There may be multiple
# intersecting markers, i.e., multiple entries for some y values
x_values = [[] for i in range(717)]
for marker in lane['markers']:
x_values[marker['pixel_start']['y']].append(
marker['pixel_start']['x'])
height = marker['pixel_start']['y'] - marker['pixel_end']['y']
if height > 2:
slope = (marker['pixel_end']['x'] -
marker['pixel_start']['x']) / height
step_size = (marker['pixel_start']['y'] -
marker['pixel_end']['y']) / float(height)
for i in range(height + 1):
x = marker['pixel_start']['x'] + slope * step_size * i
y = marker['pixel_start']['y'] - step_size * i
x_values[ir(y)].append(ir(x))
# Calculate average x values for each y value
for y, xs in enumerate(x_values):
if not xs:
x_values[y] = -1
else:
x_values[y] = sum(xs) / float(len(xs))
# In the following, we will only interpolate between markers if needed
if not between_markers:
return x_values # TODO ypp
# # interpolate between markers
current_y = 0
while x_values[current_y] == -1: # skip missing first entries
current_y += 1
# Also possible using numpy.interp when accounting for beginning and end
next_set_y = 0
try:
while current_y < 717:
if x_values[current_y] != -1: # set. Nothing to be done
current_y += 1
continue
# Finds target x value for interpolation
while next_set_y <= current_y or x_values[next_set_y] == -1:
next_set_y += 1
if next_set_y >= 717:
raise StopIteration
x_values[current_y] = x_values[current_y - 1] + (x_values[next_set_y] - x_values[current_y - 1]) /\
(next_set_y - current_y + 1)
current_y += 1
except StopIteration:
pass # Done with lane
return x_values
def _lane_points_fit(self, lane):
# TODO name and docstring
""" Fits spline in image space for the markers of a single lane (side)
Parameters
----------
lane: dict as specified in label
Returns
-------
Pixel level values for curve along the y-axis
Notes
-----
This one can be drastically improved. Probably fairly easy as well.
"""
# NOTE all variable names represent image coordinates, interpolation coordinates are swapped!
lane = _extend_lane(lane, self.json_content['projection_matrix'])
sampled_points = self._sample_points(lane, ypp=1)
self.sampled_points[lane['lane_id']] = sampled_points
return sampled_points
def create_all_points(self, ):
""" Creates splines for given label """
for lane in self.lanes:
self._lane_points_fit(lane)
def get_horizontal_values_for_four_lanes(json_path):
""" Gets an x value for every y coordinate for l1, l0, r0, r1
This allows to easily train a direct curve approximation. For each value along
the y-axis, the respective x-values can be compared, e.g. squared distance.
Missing values are filled with -1. Missing values are values missing from the spline.
There is no extrapolation to the image start/end (yet).
But values are interpolated between markers. Space between dashed markers is not missing.
Parameters
----------
json_path: str
path to label-file
Returns
-------
List of [l1, l0, r0, r1], each of which represents a list of ints the length of
the number of vertical pixels of the image
Notes
-----
The points are currently based on the splines. The splines are interpolated based on the
segmentation values. The spline interpolation has lots of room for improvement, e.g.
the lines could be interpolated in 3D, a better approach to spline interpolation could
be used, there is barely any error checking, sometimes the splines oscillate too much.
This was used for a quick poly-line regression training only.
"""
sc = SplineCreator(json_path)
sc.create_all_points()
l1 = sc.sampled_points.get('l1', [-1] * 717)
l0 = sc.sampled_points.get('l0', [-1] * 717)
r0 = sc.sampled_points.get('r0', [-1] * 717)
r1 = sc.sampled_points.get('r1', [-1] * 717)
lanes = [l1, l0, r0, r1]
return lanes
def _filter_lanes_by_size(label, min_height=40):
""" May need some tuning """
filtered_lanes = []
for lane in label['lanes']:
lane_start = min(
[int(marker['pixel_start']['y']) for marker in lane['markers']])
lane_end = max(
[int(marker['pixel_start']['y']) for marker in lane['markers']])
if (lane_end - lane_start) < min_height:
continue
filtered_lanes.append(lane)
label['lanes'] = filtered_lanes
def _filter_few_markers(label, min_markers=2):
"""Filter lines that consist of only few markers"""
filtered_lanes = []
for lane in label['lanes']:
if len(lane['markers']) >= min_markers:
filtered_lanes.append(lane)
label['lanes'] = filtered_lanes
def _fix_lane_names(label):
""" Given keys ['l3', 'l2', 'l0', 'r0', 'r2'] returns ['l2', 'l1', 'l0', 'r0', 'r1']"""
# Create mapping
l_counter = 0
r_counter = 0
mapping = {}
lane_ids = [lane['lane_id'] for lane in label['lanes']]
for key in sorted(lane_ids):
if key[0] == 'l':
mapping[key] = 'l' + str(l_counter)
l_counter += 1
if key[0] == 'r':
mapping[key] = 'r' + str(r_counter)
r_counter += 1
for lane in label['lanes']:
lane['lane_id'] = mapping[lane['lane_id']]
def read_json(json_path, min_lane_height=20):
""" Reads and cleans label file information by path"""
with open(json_path, 'r') as jf:
label_content = json.load(jf)
_filter_lanes_by_size(label_content, min_height=min_lane_height)
_filter_few_markers(label_content, min_markers=2)
_fix_lane_names(label_content)
content = {
'projection_matrix': label_content['projection_matrix'],
'lanes': label_content['lanes']
}
for lane in content['lanes']:
for marker in lane['markers']:
for pixel_key in marker['pixel_start'].keys():
marker['pixel_start'][pixel_key] = int(
marker['pixel_start'][pixel_key])
for pixel_key in marker['pixel_end'].keys():
marker['pixel_end'][pixel_key] = int(
marker['pixel_end'][pixel_key])
for pixel_key in marker['world_start'].keys():
marker['world_start'][pixel_key] = float(
marker['world_start'][pixel_key])
for pixel_key in marker['world_end'].keys():
marker['world_end'][pixel_key] = float(
marker['world_end'][pixel_key])
return content
def ir(some_value):
""" Rounds and casts to int
Useful for pixel values that cannot be floats
Parameters
----------
some_value : float
numeric value
Returns
--------
Rounded integer
Raises
------
ValueError for non scalar types
"""
return int(round(some_value))
# End code under the previous license

View File

@@ -0,0 +1,18 @@
import logging
def init_logger(log_file=None, log_level=logging.INFO):
stream_handler = logging.StreamHandler()
handlers = [stream_handler]
if log_file is not None:
file_handler = logging.FileHandler(log_file, 'w')
handlers.append(file_handler)
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s')
for handler in handlers:
handler.setFormatter(formatter)
handler.setLevel(log_level)
logging.basicConfig(level=log_level, handlers=handlers)

View File

@@ -0,0 +1,60 @@
"""Extract lane polylines from MUFLD/CULane-style segmentation masks."""
from __future__ import annotations
import numpy as np
def normalize_mask_labels(arr: np.ndarray, num_lanes: int = 4) -> np.ndarray:
"""Map MUFLD ids 0,2,3,4,5 -> 0,1,2,3,4 for seg training."""
if arr.max() <= num_lanes:
return arr.astype(np.uint8)
out = np.zeros_like(arr, dtype=np.uint8)
for lane_idx in range(1, num_lanes + 1):
out[arr == (lane_idx + 1)] = lane_idx
return out
def lane_pixel_id(lane_idx: int, mask_max: int) -> int:
if mask_max == 2:
return lane_idx
return lane_idx + 1
def lanes_from_mask(
mask: np.ndarray,
sample_ys: list | range,
num_lanes: int = 4,
) -> list[list[tuple[float, float]]]:
"""Return list of lanes; each lane is [(x,y), ...] sorted by y descending."""
if mask.ndim > 2:
mask = mask[:, :, 0]
mask = normalize_mask_labels(mask.astype(np.uint8), num_lanes)
mx = int(mask.max())
lanes = []
for lane_idx in range(1, num_lanes + 1):
vid = lane_pixel_id(lane_idx, mx)
pts = []
for y in sample_ys:
yi = int(round(y))
if yi < 0 or yi >= mask.shape[0]:
continue
xs = np.where(mask[yi] == vid)[0]
if len(xs) == 0:
continue
pts.append((float(np.mean(xs)), float(yi)))
if len(pts) >= 2:
pts = sorted(pts, key=lambda p: -p[1])
lanes.append(pts)
return lanes
def lanes_to_lines_txt(lanes: list[list[tuple[float, float]]]) -> str:
lines = []
for lane in lanes:
parts = []
for x, y in lane:
parts.append(f"{x:.5f} {y:.5f}")
if parts:
lines.append(" ".join(parts))
return "\n".join(lines) + ("\n" if lines else "")

View File

@@ -0,0 +1,58 @@
import torch
import os
from torch import nn
import numpy as np
import torch.nn.functional
def save_model(net, optim, scheduler, recorder, is_best=False):
model_dir = os.path.join(recorder.work_dir, 'ckpt')
os.system('mkdir -p {}'.format(model_dir))
epoch = recorder.epoch
ckpt_name = 'best' if is_best else epoch
torch.save(
{
'net': net.state_dict(),
'optim': optim.state_dict(),
'scheduler': scheduler.state_dict(),
'recorder': recorder.state_dict(),
'epoch': epoch
}, os.path.join(model_dir, '{}.pth'.format(ckpt_name)))
def load_network_specified(net, model_dir, logger=None):
pretrained_net = torch.load(model_dir)['net']
net_state = net.state_dict()
state = {}
for k, v in pretrained_net.items():
if k not in net_state.keys() or v.size() != net_state[k].size():
if logger:
logger.info('skip weights: ' + k)
continue
state[k] = v
net.load_state_dict(state, strict=False)
def load_network(net, model_dir, finetune_from=None, logger=None):
if finetune_from:
if logger:
logger.info('Finetune model from: ' + finetune_from)
load_network_specified(net, finetune_from, logger)
return
pretrained_model = torch.load(model_dir)
net.load_state_dict(pretrained_model['net'], strict=False)
def resume_network(model_dir, net, optim, scheduler, recorder):
if not os.path.exists(model_dir):
print('WARNING: NO MODEL LOADED !!!', 'red')
return 0
print('resume model: {}'.format(model_dir))
pretrained_model = torch.load(model_dir)
net.load_state_dict(pretrained_model['net'])
optim.load_state_dict(pretrained_model['optim'])
scheduler.load_state_dict(pretrained_model['scheduler'])
recorder.load_state_dict(pretrained_model['recorder'])
return pretrained_model['epoch'] + 1

View File

@@ -0,0 +1,135 @@
from collections import deque, defaultdict
import torch
import os
import datetime
from .logger import init_logger
import logging
import pathspec
class SmoothedValue(object):
"""Track a series of values and provide access to smoothed values over a
window or the global series average.
"""
def __init__(self, window_size=20):
self.deque = deque(maxlen=window_size)
self.total = 0.0
self.count = 0
def update(self, value):
self.deque.append(value)
self.count += 1
self.total += value
@property
def median(self):
d = torch.tensor(list(self.deque))
return d.median().item()
@property
def avg(self):
d = torch.tensor(list(self.deque))
return d.mean().item()
@property
def global_avg(self):
return self.total / self.count
class Recorder(object):
def __init__(self, cfg):
self.cfg = cfg
self.work_dir = self.get_work_dir()
cfg.work_dir = self.work_dir
self.log_path = os.path.join(self.work_dir, 'log.txt')
init_logger(self.log_path)
self.logger = logging.getLogger(__name__)
self.logger.info('Config: \n' + cfg.text)
self.save_cfg(cfg)
self.cp_projects(self.work_dir)
# scalars
self.epoch = 0
self.step = 0
self.loss_stats = defaultdict(SmoothedValue)
self.batch_time = SmoothedValue()
self.data_time = SmoothedValue()
self.max_iter = self.cfg.total_iter
self.lr = 0.
def save_cfg(self, cfg):
cfg_path = os.path.join(self.work_dir, 'config.py')
with open(cfg_path, 'w') as cfg_file:
cfg_file.write(cfg.text)
def cp_projects(self, to_path):
with open('./.gitignore', 'r') as fp:
ign = fp.read()
ign += '\n.git'
spec = pathspec.PathSpec.from_lines(
pathspec.patterns.GitWildMatchPattern, ign.splitlines())
all_files = {
os.path.join(root, name)
for root, dirs, files in os.walk('./') for name in files
}
matches = spec.match_files(all_files)
matches = set(matches)
to_cp_files = all_files - matches
for f in to_cp_files:
dirs = os.path.join(to_path, 'code', os.path.split(f[2:])[0])
if not os.path.exists(dirs):
os.makedirs(dirs)
os.system('cp %s %s' % (f, os.path.join(to_path, 'code', f[2:])))
def get_work_dir(self):
now = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
hyper_param_str = '_lr_%1.0e_b_%d' % (self.cfg.optimizer.lr,
self.cfg.batch_size)
work_dir = os.path.join(self.cfg.work_dirs, now + hyper_param_str)
if not os.path.exists(work_dir):
os.makedirs(work_dir)
return work_dir
def update_loss_stats(self, loss_dict):
for k, v in loss_dict.items():
if not isinstance(v, torch.Tensor): continue
self.loss_stats[k].update(v.detach().mean().cpu())
def record(self, prefix, step=-1, loss_stats=None, image_stats=None):
self.logger.info(self)
# self.write(str(self))
def write(self, content):
with open(self.log_path, 'a+') as f:
f.write(content)
f.write('\n')
def state_dict(self):
scalar_dict = {}
scalar_dict['step'] = self.step
return scalar_dict
def load_state_dict(self, scalar_dict):
self.step = scalar_dict['step']
def __str__(self):
loss_state = []
for k, v in self.loss_stats.items():
loss_state.append('{}: {:.4f}'.format(k, v.avg))
loss_state = ' '.join(loss_state)
recording_state = ' '.join([
'epoch: {}', 'step: {}', 'lr: {:.6f}', '{}', 'data: {:.4f}',
'batch: {:.4f}', 'eta: {}'
])
eta_seconds = self.batch_time.global_avg * (self.max_iter - self.step)
eta_string = str(datetime.timedelta(seconds=int(eta_seconds)))
return recording_state.format(self.epoch, self.step, self.lr,
loss_state, self.data_time.avg,
self.batch_time.avg, eta_string)
def build_recorder(cfg):
return Recorder(cfg)

View File

@@ -0,0 +1,82 @@
import inspect
import six
# borrow from mmdetection
def is_str(x):
"""Whether the input is an string instance."""
return isinstance(x, six.string_types)
class Registry(object):
def __init__(self, name):
self._name = name
self._module_dict = dict()
def __repr__(self):
format_str = self.__class__.__name__ + '(name={}, items={})'.format(
self._name, list(self._module_dict.keys()))
return format_str
@property
def name(self):
return self._name
@property
def module_dict(self):
return self._module_dict
def get(self, key):
return self._module_dict.get(key, None)
def _register_module(self, module_class):
"""Register a module.
Args:
module (:obj:`nn.Module`): Module to be registered.
"""
if not inspect.isclass(module_class):
raise TypeError('module must be a class, but got {}'.format(
type(module_class)))
module_name = module_class.__name__
if module_name in self._module_dict:
raise KeyError('{} is already registered in {}'.format(
module_name, self.name))
self._module_dict[module_name] = module_class
def register_module(self, cls):
self._register_module(cls)
return cls
def build_from_cfg(cfg, registry, default_args=None):
"""Build a module from config dict.
Args:
cfg (dict): Config dict. It should at least contain the key "type".
registry (:obj:`Registry`): The registry to search the type from.
default_args (dict, optional): Default initialization arguments.
Returns:
obj: The constructed object.
"""
assert isinstance(cfg, dict) and 'type' in cfg
assert isinstance(default_args, dict) or default_args is None
args = cfg.copy()
obj_type = args.pop('type')
if is_str(obj_type):
obj_cls = registry.get(obj_type)
if obj_cls is None:
raise KeyError('{} is not in the {} registry'.format(
obj_type, registry.name))
elif inspect.isclass(obj_type):
obj_cls = obj_type
else:
raise TypeError('type must be a str or valid type, but got {}'.format(
type(obj_type)))
if default_args is not None:
for name, value in default_args.items():
args.setdefault(name, value)
return obj_cls(**args)

View File

@@ -0,0 +1,136 @@
import numpy as np
from sklearn.linear_model import LinearRegression
import json as json
class LaneEval(object):
lr = LinearRegression()
pixel_thresh = 20
pt_thresh = 0.85
@staticmethod
def get_angle(xs, y_samples):
xs, ys = xs[xs >= 0], y_samples[xs >= 0]
if len(xs) > 1:
LaneEval.lr.fit(ys[:, None], xs)
k = LaneEval.lr.coef_[0]
theta = np.arctan(k)
else:
theta = 0
return theta
@staticmethod
def line_accuracy(pred, gt, thresh):
pred = np.array([p if p >= 0 else -100 for p in pred])
gt = np.array([g if g >= 0 else -100 for g in gt])
return np.sum(np.where(np.abs(pred - gt) < thresh, 1., 0.)) / len(gt)
@staticmethod
def bench(pred, gt, y_samples, running_time):
if any(len(p) != len(y_samples) for p in pred):
raise Exception('Format of lanes error.')
if running_time > 200 or len(gt) + 2 < len(pred):
return 0., 0., 1.
angles = [
LaneEval.get_angle(np.array(x_gts), np.array(y_samples))
for x_gts in gt
]
threshs = [LaneEval.pixel_thresh / np.cos(angle) for angle in angles]
line_accs = []
fp, fn = 0., 0.
matched = 0.
for x_gts, thresh in zip(gt, threshs):
accs = [
LaneEval.line_accuracy(np.array(x_preds), np.array(x_gts),
thresh) for x_preds in pred
]
max_acc = np.max(accs) if len(accs) > 0 else 0.
if max_acc < LaneEval.pt_thresh:
fn += 1
else:
matched += 1
line_accs.append(max_acc)
fp = len(pred) - matched
if len(gt) > 4 and fn > 0:
fn -= 1
s = sum(line_accs)
if len(gt) > 4:
s -= min(line_accs)
return s / max(min(4.0, len(gt)),
1.), fp / len(pred) if len(pred) > 0 else 0., fn / max(
min(len(gt), 4.), 1.)
@staticmethod
def bench_one_submit(pred_file, gt_file):
try:
json_pred = [
json.loads(line) for line in open(pred_file).readlines()
]
except BaseException as e:
raise Exception('Fail to load json file of the prediction.')
json_gt = [json.loads(line) for line in open(gt_file).readlines()]
if len(json_gt) != len(json_pred):
raise Exception(
'We do not get the predictions of all the test tasks')
gts = {l['raw_file']: l for l in json_gt}
accuracy, fp, fn = 0., 0., 0.
for pred in json_pred:
if 'raw_file' not in pred or 'lanes' not in pred or 'run_time' not in pred:
raise Exception(
'raw_file or lanes or run_time not in some predictions.')
raw_file = pred['raw_file']
pred_lanes = pred['lanes']
run_time = pred['run_time']
if raw_file not in gts:
raise Exception(
'Some raw_file from your predictions do not exist in the test tasks.'
)
gt = gts[raw_file]
gt_lanes = gt['lanes']
y_samples = gt['h_samples']
try:
a, p, n = LaneEval.bench(pred_lanes, gt_lanes, y_samples,
run_time)
except BaseException as e:
raise Exception('Format of lanes error.')
accuracy += a
fp += p
fn += n
num = len(gts)
# the first return parameter is the default ranking parameter
fp = fp / num
fn = fn / num
tp = 1 - fp
precision = tp / (tp + fp)
recall = tp / (tp + fn)
f1 = 2 * precision * recall / (precision + recall)
return json.dumps([{
'name': 'Accuracy',
'value': accuracy / num,
'order': 'desc'
}, {
'name': 'F1_score',
'value': f1,
'order': 'desc'
}, {
'name': 'FP',
'value': fp,
'order': 'asc'
}, {
'name': 'FN',
'value': fn,
'order': 'asc'
}]), accuracy / num
if __name__ == '__main__':
import sys
try:
if len(sys.argv) != 3:
raise Exception('Invalid input arguments')
print(LaneEval.bench_one_submit(sys.argv[1], sys.argv[2]))
except Exception as e:
print(e.message)
sys.exit(e.message)

View File

@@ -0,0 +1,64 @@
import cv2
import os
import os.path as osp
COLORS = [
(255, 0, 0),
(0, 255, 0),
(0, 0, 255),
(255, 255, 0),
(255, 0, 255),
(0, 255, 255),
(128, 255, 0),
(255, 128, 0),
(128, 0, 255),
(255, 0, 128),
(0, 128, 255),
(0, 255, 128),
(128, 255, 255),
(255, 128, 255),
(255, 255, 128),
(60, 180, 0),
(180, 60, 0),
(0, 60, 180),
(0, 180, 60),
(60, 0, 180),
(180, 0, 60),
(255, 0, 0),
(0, 255, 0),
(0, 0, 255),
(255, 255, 0),
(255, 0, 255),
(0, 255, 255),
(128, 255, 0),
(255, 128, 0),
(128, 0, 255),
]
def imshow_lanes(img, lanes, show=False, out_file=None, width=4):
lanes_xys = []
for _, lane in enumerate(lanes):
xys = []
for x, y in lane:
if x <= 0 or y <= 0:
continue
x, y = int(x), int(y)
xys.append((x, y))
lanes_xys.append(xys)
lanes_xys.sort(key=lambda xys : xys[0][0])
for idx, xys in enumerate(lanes_xys):
for i in range(1, len(xys)):
cv2.line(img, xys[i - 1], xys[i], COLORS[idx], thickness=width)
if show:
cv2.imshow('view', img)
cv2.waitKey(0)
if out_file:
if not osp.exists(osp.dirname(out_file)):
os.makedirs(osp.dirname(out_file))
cv2.imwrite(out_file, img)