diff --git a/README.md b/README.md index 421cd3c..13118e8 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,21 @@ -# Веб-сервис для отладки Robossembler Framework +# Веб-сервис Robossembler -Необходимость разработки сервиса хранения и просмотра пакетов обусловлена тем, что для корректной работы фреймворка «Робосборщик» необходима согласованная работа разнообразных программный модулей – результаты работы одних модулей должны передаваться через стандартизированные интерфейсы другим модулям. Как правило, результатами работы программных модулей являются исполняемые файлы программ, файлы 3D-моделей в форматах STL, FBX, Collada/DAE, OBJ, PLY и т.п., конфигурационные файлы в форматах yaml, json, ini, txt, веса нейронных сетей, описания роботов/сцен в форматах URDF, SDF, MJCF и т.д.. При этом необходимо соблюсти условие соответствия данных файлов/документов друг другу, иметь возможность формировать и отслеживать цепочки вычислений (конвейер, pipeline), которые их порождают. +Сервис для сопровождении процесса/жизненного цикла разработки программ сборки изделий роботами и интеграции программных модулей [Фреймворка Робосборщик](https://gitlab.com/robossembler/framework). -Данный веб-сервис выполняет следующие функции: +## Мотивация -- Создание процессов (process) – команд, запускающих определённые вычисления -- Создание триггеров (trigger) – событий, запускающихся по завершении процесса -- Создание конвейеров вычислений (pipeline) – цепочек из процессов -- Создание проектов (project) – набора конвейеров для выполнения прикладных задач -- Хранение и просмотр артефактов, порождаемых процессами, а также отслеживание их жизненного цикла -- Запуск процессов/конвейеров и отслеживание их состояния +Для корректной работы фреймворка необходима согласованная работа разнообразных программный модулей – результаты работы одних модулей должны передаваться через стандартизированные интерфейсы другим модулям. Результатами работы программных модулей являются исполняемые файлы программ, файлы 3D-моделей в форматах STL, FBX, Collada/DAE, OBJ, PLY и т.п., конфигурационные файлы в форматах yaml, json, ini, txt, веса нейронных сетей, описания роботов/сцен в форматах URDF, SDF, MJCF и т.д. + +## Состав модулей сервиса + +Каждая фаза жизненного цикла имеет своё представление в виде страницы в веб-сервисе: + +1. Создание проекта сборки, загрузка CAD-проекта изделия - "Проекты", вкладки "Детали", "Сборки" +2. Подготовка и генерация датасета для навыков машинного зрения - Вкладка "Датасеты" +3. Конфигурация сцены - Scene Builder - Вкладка "Сцена" +4. Создание дерева поведения из навыков - Вкладка "Поведение" +5. Просмотр результатов симуляции - Вкладка "Симуляция" +6. Оценка производительности навыков Вкладка "Анализ" Веб-сервис написан на языке TypeScript для среды исполнения NodeJS. Для хранения артефактов используется база данных MongoDB. Исходный код проекта разработан в соответствии с концепцией «Чистой архитектуры», описанной Робертом Мартином в одноимённой книге. Данный подход позволяет систематизировать код, отделить бизнес-логику от остальной части приложения. @@ -19,11 +25,35 @@ - Node.js - MongoDB +- BlenderProc (для генерации датасетов) -## Сборка UI +## Клонирование проекта -- `cd ui && npm i && npm run build && npm run deploy` +```bash +git clone https://gitlab.com/robossembler/webservice +``` -# Запуск сервиса +## Настройка переменных окружения -- `cd server && npm run dev` +Для работы Генератора Датасетов нужно задать следующие переменные в окружении `bash` + +```bash +export PYTHON_BLENDER="путь_к_директории_с_файлами_из_rcg_pipeline" +export PYTHON_BLENDER_PROC="путь_к_генератору_датасетов_renderBOPdataset.py" +``` + +## Запуск сервера + +Из директории `server` в корне репозитория + +```bash +npm run dev +``` + +## Сборка и запуск UI + +Из директории `ui` в корне репозитория + +```bash +npm i && npm run build && npm run deploy +``` diff --git a/server/src/core/controllers/routes.ts b/server/src/core/controllers/routes.ts index fb869b5..1710aae 100644 --- a/server/src/core/controllers/routes.ts +++ b/server/src/core/controllers/routes.ts @@ -1,7 +1,6 @@ import { BehaviorTreesPresentation } from "../../features/behavior_trees/behavior_trees"; import { DatasetsPresentation } from "../../features/datasets/datasets_presentation"; import { ProjectsPresentation } from "../../features/projects/projects_presentation"; -// import { ProjectsPresentation } from "../../features/_projects/projects_presentation"; import { extensions } from "../extensions/extensions"; import { Routes } from "../interfaces/router"; diff --git a/ui/src/core/ui/button/button.tsx b/ui/src/core/ui/button/button.tsx index bf52d57..3db5865 100644 --- a/ui/src/core/ui/button/button.tsx +++ b/ui/src/core/ui/button/button.tsx @@ -6,22 +6,25 @@ export interface IButtonProps { filled?: boolean; text?: string; onClick?: any; - style?:React.CSSProperties + style?: React.CSSProperties; } export function CoreButton(props: IButtonProps) { return (
props.onClick?.call()} - style={Object.assign({ - backgroundColor: props.filled ? "rgba(103, 80, 164, 1)" : "", - paddingRight: 20, - paddingLeft: 20, - paddingTop: 10, - paddingBottom: 10, - borderRadius: 24, - border: props.block ? "1px solid rgba(29, 27, 32, 0.12)" : props.filled ? "" : "1px solid black", - },props.style)} + style={Object.assign( + { + backgroundColor: props.filled ? "rgba(103, 80, 164, 1)" : "", + paddingRight: 20, + paddingLeft: 20, + paddingTop: 10, + paddingBottom: 10, + borderRadius: 24, + border: props.block ? "1px solid rgba(29, 27, 32, 0.12)" : props.filled ? "" : "1px solid black", + }, + props.style + )} > ); } - - - \ No newline at end of file diff --git a/ui/src/features/behavior_tree_builder/model/editor_view.ts b/ui/src/features/behavior_tree_builder/model/editor_view.ts index 360722b..213b0b5 100644 --- a/ui/src/features/behavior_tree_builder/model/editor_view.ts +++ b/ui/src/features/behavior_tree_builder/model/editor_view.ts @@ -61,7 +61,6 @@ export class BtBuilderModel { } public static getBtPriorities(ids: string[]) {} - public static findSequence( nodeId: string, editor: NodeEditor, diff --git a/ui/src/features/behavior_tree_builder/presentation/behavior_tree_builder_screen.tsx b/ui/src/features/behavior_tree_builder/presentation/behavior_tree_builder_screen.tsx index 4cee1da..46e06c4 100644 --- a/ui/src/features/behavior_tree_builder/presentation/behavior_tree_builder_screen.tsx +++ b/ui/src/features/behavior_tree_builder/presentation/behavior_tree_builder_screen.tsx @@ -1,5 +1,4 @@ import * as React from "react"; - import { useRete } from "rete-react-plugin"; import { createEditor } from "./ui/editor/editor"; import { SkillTree } from "./ui/skill_tree/skill_tree"; diff --git a/ui/src/features/behavior_tree_builder/presentation/ui/editor/editor.tsx b/ui/src/features/behavior_tree_builder/presentation/ui/editor/editor.tsx index 0d704cf..65df94b 100644 --- a/ui/src/features/behavior_tree_builder/presentation/ui/editor/editor.tsx +++ b/ui/src/features/behavior_tree_builder/presentation/ui/editor/editor.tsx @@ -34,7 +34,6 @@ export async function createEditor(container: HTMLElement) { }); observer.on(() => { - console.log(200) behaviorTreeBuilderStore.bt(editor, areaContainer); }); diff --git a/web_p/blender.py b/web_p/blender.py new file mode 100644 index 0000000..923555d --- /dev/null +++ b/web_p/blender.py @@ -0,0 +1,20 @@ +import shutil +import argparse +import os.path + +parser = argparse.ArgumentParser() +parser.add_argument("--path") +args = parser.parse_args() + +def copy_and_move_folder(src, dst): + try: + if os.path.exists(dst): + shutil.rmtree(dst) + shutil.copytree(src, dst) + print(f"Folder {src} successfully copied") + except shutil.Error as e: + print(f"Error: {e}") + +source_folder = os.path.dirname(os.path.abspath(__file__)) + "/blender" #"/home/shalenikol/web_p/blender/" + +copy_and_move_folder(source_folder, args.path) \ No newline at end of file diff --git a/web_p/blender/assets/assets.json b/web_p/blender/assets/assets.json new file mode 100644 index 0000000..f661cbb --- /dev/null +++ b/web_p/blender/assets/assets.json @@ -0,0 +1,6 @@ +{ + "assets": [ + { "name": "bear_holder", "mesh": "./mesh/fork.stl", "image": "./images/bear_holder_220425.png" }, + { "name": "bear_holder1", "mesh": "./mesh/fork.stl", "image": "./images/bear_holder_220425.png" } + ] +} diff --git a/web_p/blender/assets/images/bear_holder_220425.png b/web_p/blender/assets/images/bear_holder_220425.png new file mode 100644 index 0000000..a366b36 Binary files /dev/null and b/web_p/blender/assets/images/bear_holder_220425.png differ diff --git a/web_p/blender/assets/mesh/floor.fbx b/web_p/blender/assets/mesh/floor.fbx new file mode 100644 index 0000000..0c904fd Binary files /dev/null and b/web_p/blender/assets/mesh/floor.fbx differ diff --git a/web_p/blender/assets/mesh/fork.fbx b/web_p/blender/assets/mesh/fork.fbx new file mode 100644 index 0000000..891e3a2 Binary files /dev/null and b/web_p/blender/assets/mesh/fork.fbx differ diff --git a/web_p/blender/assets/mesh/fork.obj b/web_p/blender/assets/mesh/fork.obj new file mode 100644 index 0000000..e69de29 diff --git a/web_p/blender/assets/mesh/fork.stl b/web_p/blender/assets/mesh/fork.stl new file mode 100644 index 0000000..e69de29 diff --git a/web_p/impassable object.FCStd b/web_p/impassable object.FCStd new file mode 100644 index 0000000..d2a87a5 Binary files /dev/null and b/web_p/impassable object.FCStd differ diff --git a/web_p/renderBOPdataset.py b/web_p/renderBOPdataset.py new file mode 100755 index 0000000..f3d8b15 --- /dev/null +++ b/web_p/renderBOPdataset.py @@ -0,0 +1,370 @@ +import blenderproc as bproc +""" + renderBOPdataset + Общая задача: common pipeline + Реализуемая функция: создание датасета в формате BOP с заданными параметрами рандомизации + Используется модуль blenderproc + + 19.04.2024 @shalenikol release 0.1 +""" +import numpy as np +import argparse +import random +import os +import shutil +import json + +VHACD_PATH = "blenderproc_resources/vhacd" +# DIR_BOP = "bop_data" +DIR_MODELS = "models" +FILE_LOG_SCENE = "res.txt" +FILE_RBS_INFO = "rbs_info.json" +FILE_GT_COCO = "scene_gt_coco.json" + +Not_Categories_Name = True # наименование категории в COCO-аннотации отсутствует + +def _get_path_model(name_model: str) -> str: + # TODO on name_model find path for mesh (model.fbx) + # local_path/assets/mesh/ + return os.path.join(rnd_par.output_dir, "assets/mesh/"+name_model+".fbx") + # , d: dict + # return d["model"] + +def _get_path_object(name_obj: str) -> str: + # TODO on name_obj find path for scene object (object.fbx) + return os.path.join(rnd_par.output_dir, "assets/mesh/"+name_obj+".fbx") + # , d: dict + # return d["path"] + +def convert2relative(height, width, bbox): + """ + YOLO format use relative coordinates for annotation + """ + x, y, w, h = bbox + x += w/2 + y += h/2 + return x/width, y/height, w/width, h/height + +def render() -> int: + for obj in all_meshs: + # Make the object actively participate in the physics simulation + obj.enable_rigidbody(active=True, collision_shape="COMPOUND") + # Also use convex decomposition as collision shapes + obj.build_convex_decomposition_collision_shape(VHACD_PATH) + + objs = all_meshs + rnd_par.scene.objs + + log_txt = os.path.join(rnd_par.output_dir, FILE_LOG_SCENE) + with open(log_txt, "w") as fh: + for i,o in enumerate(objs): + loc = o.get_location() + euler = o.get_rotation_euler() + fh.write(f"{i} : {o.get_name()} {loc} {euler} category_id = {o.get_cp('category_id')}\n") + + # define a light and set its location and energy level + ls = [] + for l in rnd_par.scene.light_data: + light = bproc.types.Light(name=f"l{l['id']}") + light.set_type(l["type"]) + light.set_location(l["loc_xyz"]) #[5, -5, 5]) + light.set_rotation_euler(l["rot_euler"]) #[-0.063, 0.6177, -0.1985]) + ls += [light] + + # define the camera intrinsics + bproc.camera.set_intrinsics_from_blender_params(1, + rnd_par.image_size_wh[0], + rnd_par.image_size_wh[1], + lens_unit="FOV") + + # add segmentation masks (per class and per instance) + bproc.renderer.enable_segmentation_output(map_by=["category_id", "instance", "name"]) + + # activate depth rendering + bproc.renderer.enable_depth_output(activate_antialiasing=False) + + res_dir = os.path.join(rnd_par.output_dir, rnd_par.ds_name) + if os.path.isdir(res_dir): + shutil.rmtree(res_dir) + # Цикл рендеринга + # Do multiple times: Position the shapenet objects using the physics simulator and render X images with random camera poses + for r in range(rnd_par.n_series): + # один случайный объект в кадре / все заданные объекты + random_obj = random.choice(range(rnd_par.scene.n_obj)) + meshs = [] + for i,o in enumerate(all_meshs): #objs + if rnd_par.single_object and i != random_obj: + continue + meshs += [o] + rnd_mat = rnd_par.scene.obj_data[i]["material_randomization"] + mats = o.get_materials() #[0] + for mat in mats: + val = rnd_mat["specular"] + mat.set_principled_shader_value("Specular", random.uniform(val[0], val[1])) + val = rnd_mat["roughness"] + mat.set_principled_shader_value("Roughness", random.uniform(val[0], val[1])) + val = rnd_mat["base_color"] + mat.set_principled_shader_value("Base Color", np.random.uniform(val[0], val[1])) + val = rnd_mat["metallic"] + mat.set_principled_shader_value("Metallic", random.uniform(val[0], val[1])) + + # Randomly set the color and energy + for i,l in enumerate(ls): + current = rnd_par.scene.light_data[i] + l.set_color(np.random.uniform(current["color_range_low"], current["color_range_high"])) + energy = current["energy_range"] + l.set_energy(random.uniform(energy[0], energy[1])) + + # Clear all key frames from the previous run + bproc.utility.reset_keyframes() + + # Define a function that samples 6-DoF poses + def sample_pose(obj: bproc.types.MeshObject): + obj.set_location(np.random.uniform(rnd_par.loc_range_low, rnd_par.loc_range_high)) #[-1, -1, 0], [1, 1, 2])) + obj.set_rotation_euler(bproc.sampler.uniformSO3()) + + # Sample the poses of all shapenet objects above the ground without any collisions in-between + bproc.object.sample_poses(meshs, + objects_to_check_collisions = meshs + rnd_par.scene.collision_objects, + sample_pose_func = sample_pose) + + # Run the simulation and fix the poses of the shapenet objects at the end + bproc.object.simulate_physics_and_fix_final_poses(min_simulation_time=4, max_simulation_time=20, check_object_interval=1) + + # Find point of interest, all cam poses should look towards it + poi = bproc.object.compute_poi(meshs) + + coord_max = [0.1, 0.1, 0.1] + coord_min = [0., 0., 0.] + + with open(log_txt, "a") as fh: + fh.write("*****************\n") + fh.write(f"{r}) poi = {poi}\n") + i = 0 + for o in meshs: + i += 1 + loc = o.get_location() + euler = o.get_rotation_euler() + fh.write(f" {i} : {o.get_name()} {loc} {euler}\n") + for j in range(3): + if loc[j] < coord_min[j]: + coord_min[j] = loc[j] + if loc[j] > coord_max[j]: + coord_max[j] = loc[j] + + # Sample up to X camera poses + #an = np.random.uniform(0.78, 1.2) #1. #0.35 + for i in range(rnd_par.n_cam_pose): + # Sample location + location = bproc.sampler.shell(center=rnd_par.center_shell, + radius_min=rnd_par.radius_range[0], + radius_max=rnd_par.radius_range[1], + elevation_min=rnd_par.elevation_range[0], + elevation_max=rnd_par.elevation_range[1]) + # координата, по которой будем сэмплировать положение камеры + j = random.randint(0, 2) + # разовый сдвиг по случайной координате + d = (coord_max[j] - coord_min[j]) / rnd_par.n_sample_on_pose + if location[j] < 0: + d = -d + for _ in range(rnd_par.n_sample_on_pose): + # Compute rotation based on vector going from location towards poi + rotation_matrix = bproc.camera.rotation_from_forward_vec(poi - location, inplane_rot=np.random.uniform(-0.7854, 0.7854)) + # Add homog cam pose based on location an rotation + cam2world_matrix = bproc.math.build_transformation_mat(location, rotation_matrix) + bproc.camera.add_camera_pose(cam2world_matrix) + location[j] -= d + # render the whole pipeline + data = bproc.renderer.render() + # Write data to bop format + bproc.writer.write_bop(res_dir, + target_objects = all_meshs, # Optional[List[MeshObject]] = None + depths = data["depth"], + depth_scale = 1.0, + colors = data["colors"], + color_file_format=rnd_par.image_format, + append_to_existing_output = (r>0), + save_world2cam = False) # world coords are arbitrary in most real BOP datasets + # dataset="robo_ds", + + models_dir = os.path.join(res_dir, DIR_MODELS) + os.mkdir(models_dir) + + data = [] + for i,objn in enumerate(rnd_par.models.names): + rec = {} + rec["id"] = i+1 + rec["name"] = objn + rec["model"] = os.path.join(DIR_MODELS, os.path.split(rnd_par.models.filenames[i])[1]) # путь относительный + t = [obj.get_bound_box(local_coords=True).tolist() for obj in all_meshs if obj.get_name() == objn] + rec["cuboid"] = t[0] + data.append(rec) + # ff = os.path.join(args.obj_path, rnd_par.models.filenames[i]) # путь к исходному файлу + # shutil.copy2(ff, models_dir) + shutil.copy2(rnd_par.models.filenames[i], models_dir) + f = (os.path.splitext(rnd_par.models.filenames[i]))[0] + ".mtl" # файл материала + if os.path.isfile(f): + shutil.copy2(f, models_dir) + + with open(os.path.join(res_dir, FILE_RBS_INFO), "w") as fh: + json.dump(data, fh, indent=2) + + """ + !!! categories -> name берётся из category_id !!! + см.ниже + blenderproc.python.writer : BopWriterUtility.py + class _BopWriterUtility + def calc_gt_coco + ... + CATEGORIES = [{'id': obj.get_cp('category_id'), 'name': str(obj.get_cp('category_id')), 'supercategory': + dataset_name} for obj in dataset_objects] + + поэтому заменим наименование категории в аннотации + """ + def change_categories_name(dir: str): + coco_file = os.path.join(dir,FILE_GT_COCO) + with open(coco_file, "r") as fh: + data = json.load(fh) + cats = data["categories"] + + for i,cat in enumerate(cats): + cat["name"] = rnd_par.models.names[i] #obj_names[i] + + with open(coco_file, "w") as fh: + json.dump(data, fh, indent=0) + + def explore(path: str): + if not os.path.isdir(path): + return + folders = [ + os.path.join(path, o) + for o in os.listdir(path) + if os.path.isdir(os.path.join(path, o)) + ] + for path_entry in folders: + print(path_entry) + if os.path.isfile(os.path.join(path_entry,FILE_GT_COCO)): + change_categories_name(path_entry) + else: + explore(path_entry) + + if Not_Categories_Name: + explore(res_dir) + return 0 # success + +def _get_models(par, data) -> int: + global all_meshs + + par.models = lambda: None + par.models.n_item = len(data) + if par.models.n_item == 0: + return 0 # no models + + # загрузим объекты + par.models.names = [] #list(map(lambda x: x["name"], data)) # obj_names + par.models.filenames = [] #list(map(lambda x: x["model"], data)) #obj_filenames + i = 1 + for f in data: + nam = f + par.models.names.append(nam) + ff = _get_path_model(nam) + # ff = f["model"] # путь к файлу объекта + par.models.filenames.append(ff) + if not os.path.isfile(ff): + print(f"Error: no such file '{ff}'") + return -1 + obj = bproc.loader.load_obj(ff) + all_meshs += obj + obj[0].set_cp("category_id", i) #f["id"]) # начиная с 1 + i += 1 + return par.models.n_item + +def _get_scene(par, data) -> int: + # load scene + par.scene = lambda: None + objs = data["objects"] + par.scene.n_obj = len(objs) + if par.scene.n_obj == 0: + return 0 # empty scene + lights = data["lights"] + par.scene.n_light = len(lights) + if par.scene.n_light == 0: + return 0 # no lighting + + par.scene.objs = [] + par.scene.collision_objects = [] + for f in objs: + ff = _get_path_object(f["name"]) # f["path"] + if not os.path.isfile(ff): + print(f"Error: no such file '{ff}'") + return -1 + obj = bproc.loader.load_obj(ff) + obj[0].set_cp("category_id", 999) + coll = f["collision_shape"] + if len(coll) > 0: + obj[0].enable_rigidbody(False, collision_shape=coll) + par.scene.collision_objects += obj + par.scene.objs += obj #bproc.loader.load_blend(args.scene, data_blocks=["objects"]) + + if not par.scene.collision_objects: + print("Collision objects not found in the scene") + return 0 + par.scene.obj_data = objs + par.scene.light_data = lights + return par.scene.n_obj + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--cfg", required=True, help="Json-string with dataset parameters") + args = parser.parse_args() + + if args.cfg[-5:] == ".json": + if not os.path.isfile(args.cfg): + print(f"Error: no such file '{args.cfg}'") + exit(-1) + with open(args.cfg, "r") as f: + j_data = f.read() + else: + j_data = args.cfg + try: + cfg = json.loads(j_data) + except json.JSONDecodeError as e: + print(f"JSon error: {e}") + exit(-2) + + ds_cfg = cfg["formBuilder"]["output"] # dataset config + generation = ds_cfg["generation"] + cam_pos = ds_cfg["camera_position"] + models_randomization = ds_cfg["models_randomization"] + + rnd_par = lambda: None + rnd_par.single_object = True + rnd_par.ds_name = cfg["name"] + rnd_par.output_dir = cfg["local_path"] + rnd_par.dataset_objs = cfg["dataSetObjects"] + rnd_par.n_cam_pose = generation["n_cam_pose"] + rnd_par.n_sample_on_pose = generation["n_sample_on_pose"] + rnd_par.n_series = generation["n_series"] + rnd_par.image_format = generation["image_format"] + rnd_par.image_size_wh = generation["image_size_wh"] + rnd_par.center_shell = cam_pos["center_shell"] + rnd_par.radius_range = cam_pos["radius_range"] + rnd_par.elevation_range = cam_pos["elevation_range"] + rnd_par.loc_range_low = models_randomization["loc_range_low"] + rnd_par.loc_range_high = models_randomization["loc_range_high"] + + if not os.path.isdir(rnd_par.output_dir): + # os.mkdir(rnd_par.output_dir) + print(f"Error: invalid path '{rnd_par.output_dir}'") + exit(-3) + + bproc.init() + + all_meshs = [] + ret = _get_models(rnd_par, rnd_par.dataset_objs) + if ret <= 0: + print("Error: no models in config") + exit(-4) + if _get_scene(rnd_par, ds_cfg["scene"]) == 0: + print("Error: empty scene in config") + exit(-5) + exit(render()) \ No newline at end of file