diff --git a/docs/docs/usage/job_description/config_descriptions/crop.md b/docs/docs/usage/job_description/config_descriptions/crop.md new file mode 100644 index 0000000..206e0e6 --- /dev/null +++ b/docs/docs/usage/job_description/config_descriptions/crop.md @@ -0,0 +1,49 @@ +# Crop Plugin Documentation + +The Crop Plugin scatters 3D models on a floor object in a grid pattern to simulate crop rows. + +## Configuration Parameters + +The following table describes each configuration parameter for the Crop Plugin: + +| Parameter | Type | Description | Requirement | +|-----------|------|-------------|-------------| +| `name` | string | Unique identifier of the plugin | **Required** | +| `models` | array or single item | 3D assets to scatter. | **Required** | +| `floor_object` | string | Name of the floor object to scatter on. | **Required** | +| `max_texture_size` | integer | Maximum texture size in pixel. Will reduce the texture to save GPU RAM. | Optional | +| `density_map` | image/texture evaluation | Texture that alters the density. It is normalized to 0-1. | Optional | +| `decimate_mesh_factor` | number (0-1) | Factor between 0-1 that decimates the number of vertices of the mesh. Lower means less vertices. | Optional | +| `scale_standard_deviation` | number evaluation | Scale variance of the scattered objects. | **Required** | +| `class_id` | integer | Class ID for ground truth output. | **Required** | +| `crop_angle` | number evaluation | Global orientation of the row direction in degrees. | **Required** | +| `row_distance` | number evaluation | Distance between rows in meters. | **Required** | +| `row_standard_deviation` | number evaluation | Standard deviation of the row distance in meters. | **Required** | +| `plant_distance` | number evaluation | Intra row distance between plants in meters. | **Required** | +| `plant_standard_deviation` | number evaluation | Standard deviation of the intra row distance in meters. | **Required** | + +### Dynamic Evaluators + +Most parameters, like `scale_standard_deviation`, `crop_angle` etc., can be dynamically evaluated. This means that their values can be altered for each new frame. For more insights on dynamic evaluators and how to use them, kindly refer to [Dynamic Evaluators](../dynamic_evaluators.md). + +## Example Configuration + +```yaml +scene: + syclops_plugin_crop: + - name: "Corn Crop" + models: Example Assets/Corn + floor_object: "Ground" + max_texture_size: 2048 + scale_standard_deviation: 0.1 + class_id: 2 + crop_angle: 45 + row_distance: 1 + row_standard_deviation: 0.1 + plant_distance: 0.3 + plant_standard_deviation: 0.05 +``` + +The above configuration will scatter corn models across the ground surface in a grid pattern resembling crop rows. The rows will be oriented at a 45 degree angle, with 1 meter spacing between rows and 30 cm spacing between plants. The row and plant spacings will vary according to the specified standard deviations. + + diff --git a/docs/docs/usage/job_description/config_descriptions/simulated_scatter.md b/docs/docs/usage/job_description/config_descriptions/simulated_scatter.md new file mode 100644 index 0000000..77f291a --- /dev/null +++ b/docs/docs/usage/job_description/config_descriptions/simulated_scatter.md @@ -0,0 +1,41 @@ +# Simulated Scatter Plugin Documentation + +The Simulated Scatter Plugin scatters 3D assets on a floor object and simulates physics to drop them realistically on the surface. + +## Configuration Parameters + +The following table describes each configuration parameter for the Simulated Scatter Plugin: + +| Parameter | Type | Description | Requirement | +|-----------|------|-------------|-------------| +| `name` | string | Unique identifier of the plugin | **Required** | +| `models` | array or single item | 3D assets to scatter. | **Required** | +| `floor_object` | string | Name of the floor object to scatter on. | **Required** | +| `max_texture_size` | integer | Maximum texture size in pixels. Will reduce the texture to save GPU RAM. | Optional | +| `decimate_mesh_factor` | number (0-1) | Factor between 0-1 that decimates the number of vertices of the mesh. Lower means less vertices. | Optional | +| `density` | number | Density of objects per square meter. | **Required** | +| `density_texture` | image/texture evaluation | Texture that alters the density per pixel. Needs to be a single channel image that is normalized to 0-1. | Optional | +| `scale_std` | number | Standard deviation of the scale randomization. | **Required** | +| `convex_decomposition_quality` | integer (1-100) | Quality setting for the convex decomposition. Higher means more accurate but slower. | **Required** | +| `simulation_steps` | integer | Number of simulation steps to run. | **Required** | + +### Dynamic Evaluators + +Parameters like `density_texture` and `scale_std` can be dynamically evaluated. This means that their values can be altered for each new frame. For more insights on dynamic evaluators and how to use them, kindly refer to [Dynamic Evaluators](../dynamic_evaluators.md). + +## Example Configuration + +```yaml +scene: + syclops_plugin_simulated_scatter: + - name: "Rock Scatter" + models: Example Assets/Rocks + floor_object: "Ground" + max_texture_size: 2048 + density: 5 + scale_std: 0.3 + convex_decomposition_quality: 90 + simulation_steps: 100 +``` + +The above configuration will scatter rock models across the ground surface. The rocks will be dropped from above and settle into physically realistic positions using a convex decomposition simulation. The simulation will run for 100 steps to allow the rocks to come to rest. \ No newline at end of file diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index c2205f4..a38d608 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -58,6 +58,8 @@ nav: - Environment: usage/job_description/config_descriptions/environment.md - Object: usage/job_description/config_descriptions/object.md - Scatter: usage/job_description/config_descriptions/scatter.md + - Crop: usage/job_description/config_descriptions/crop.md + - Simulated Scatter: usage/job_description/config_descriptions/simulated_scatter.md - Sensor Configuration: - Camera: usage/job_description/config_descriptions/camera.md - Output Configuration: diff --git a/pyproject.toml b/pyproject.toml index 203b3fc..819478c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,8 @@ syclops_plugin_ground = "syclops.blender.plugins.ground:Ground" syclops_plugin_environment = "syclops.blender.plugins.environment:Environment" syclops_plugin_scatter = "syclops.blender.plugins.scatter:Scatter" syclops_plugin_object = "syclops.blender.plugins.object:Object" +syclops_plugin_simulated_scatter = "syclops.blender.plugins.simulated_scatter:SimulatedScatter" +syclops_plugin_crop = "syclops.blender.plugins.crop:Crop" [project.entry-points."syclops.sensors"] syclops_sensor_camera = "syclops.blender.sensors.camera:Camera" [project.entry-points."syclops.outputs"] diff --git a/syclops/__example_assets__/example_job.syclops.yaml b/syclops/__example_assets__/example_job.syclops.yaml index 8d5835c..96f8f1f 100644 --- a/syclops/__example_assets__/example_job.syclops.yaml +++ b/syclops/__example_assets__/example_job.syclops.yaml @@ -51,18 +51,22 @@ scene: environment_image: random_selection: [Example Assets/Sunflower Field] - syclops_plugin_scatter: - - name: "Corn Scatter" + syclops_plugin_crop: + - name: "Corn" models: [Example Assets/Corn] floor_object: Ground - max_texture_size: 512 - density_max: 10 # per m^2 - distance_min: 0.05 # m + crop_angle: 0 # degrees; [-90, 90] possible + row_distance: 0.6 # m + row_standard_deviation: 0.2 # m + plant_distance: 0.1 + plant_standard_deviation: 0.1 # m scale_standard_deviation: 0.5 class_id: 2 class_id_offset: Stem: 1 seed: 1 + + syclops_plugin_scatter: - name: "Weed Scatter" models: [Example Assets/Plain Weeds] floor_object: Ground diff --git a/syclops/__example_assets__/test_job.syclops.yaml b/syclops/__example_assets__/test_job.syclops.yaml index 12c3749..22aa8b8 100644 --- a/syclops/__example_assets__/test_job.syclops.yaml +++ b/syclops/__example_assets__/test_job.syclops.yaml @@ -9,19 +9,19 @@ denoising_algorithm: "OPENIMAGEDENOISE" # TRANSFORMATION CONFIG transformations: - map: + map: location: [0, 0, 0] rotation: [0, 0, 0] children: camera_link: location: - linear: [[-20,0,2],[0.5,0,0]] - rotation: - normal: [[0.785398, 0, 0],[0.05,0.05,0.05]] + linear: [[-20, 0, 2], [0.5, 0, 0]] + rotation: + normal: [[0.785398, 0, 0], [0.05, 0.05, 0.05]] iso_object: location: - uniform: [[-20,-20,0],[20,20,0]] - rotation: [0,0,0] + uniform: [[-20, -20, 0], [20, 20, 0]] + rotation: [0, 0, 0] textures: plain_noise: @@ -48,27 +48,35 @@ scene: syclops_plugin_environment: - type: hdri - environment_image: + environment_image: random_selection: [Example Assets/Sunflower Field] - syclops_plugin_scatter: - - name: "Corn Scatter" + syclops_plugin_simulated_scatter: + - name: "ISO Object Scatter" + models: Example Assets/ISO Object + floor_object: Ground + class_id: 1 + simulation_steps: 5 + std_scale: 1 + density: 0.01 + convex_decomposition_quality: 90 + + syclops_plugin_crop: + - name: "Corn" models: [Example Assets/Corn] floor_object: Ground - max_texture_size: 512 - density_max: 10 # per m^2 - distance_min: 0.05 # m + crop_angle: 0 # degrees; [-90, 90] possible + row_distance: 0.6 # m + row_standard_deviation: 0.2 # m + plant_distance: 0.1 + plant_standard_deviation: 0.1 # m scale_standard_deviation: 0.5 class_id: 2 class_id_offset: Stem: 1 seed: 1 - clumps: - ratio: 0.3 - size: 3 - size_std: 2 - position_std: 0.02 - scale_std: 0.4 + + syclops_plugin_scatter: - name: "Weed Scatter" models: [Example Assets/Plain Weeds] floor_object: Ground @@ -93,7 +101,6 @@ scene: models: [Example Assets/ISO Object] floor_object: Ground - # SENSOR CONFIG sensor: syclops_sensor_camera: @@ -151,7 +158,7 @@ sensor: postprocessing: syclops_postprocessing_bounding_boxes: - type: "YOLO" - classes_to_skip: [0,1] + classes_to_skip: [0, 1] id: yolo_bound_boxes sources: ["main_cam_instance", "main_cam_semantic"] - multiple_bb_per_instance: False \ No newline at end of file + multiple_bb_per_instance: False diff --git a/syclops/blender/plugins/crop.py b/syclops/blender/plugins/crop.py new file mode 100644 index 0000000..38245bc --- /dev/null +++ b/syclops/blender/plugins/crop.py @@ -0,0 +1,80 @@ +"""This module contains the Crop plugin for Syclops. + +It is used to place objects in a row like fashion to simulate crop. +""" + +import logging + +import bpy +from syclops import utility +from syclops.blender.plugins.plugin_interface import PluginInterface + + +class Crop(PluginInterface): + """Plugin scatters objects in a row like fashion to simulate crop.""" + + crop: utility.ObjPointer + + def load(self): + """Load everything into the scene but does not configure it.""" + self.create_base_object() + self.load_instance_objects() + self.load_geometry_nodes() + logging.info("Crop: {0} loaded".format(self.config["name"])) + + def load_geometry_nodes(self): + """Load geometry nodes from .blend file and assigns them to the crop.""" + # Add crop node group to the scene + node_group_name = "Crop" + blend_path = utility.abs_path("./plugin_data/crop.blend") + + crop_nodes = utility.load_from_blend( + blend_path, + "node_groups", + node_group_name, + )[0] + + # Create GeoNode Modifier + self.geo_node_modifier = self.crop.get().modifiers.new( + self.config["name"], + "NODES", + ) + self.geo_node_modifier.show_viewport = False + self.geo_node_modifier.node_group = crop_nodes + + def load_instance_objects(self): + """Load crop geometry.""" + # Create new collection and assign a pointer + self.instance_objects = utility.ObjPointer( + utility.create_collection("{0}_Objs".format(self.config["name"])), + ) + utility.set_active_collection(self.instance_objects.get()) + + # Import the geometry + loaded_objs = utility.import_assets(self.config["models"]) + for obj in loaded_objs: + self.reduce_size(obj) + obj.hide_set(True) + self.write_config(obj) + self.instance_objects.get().hide_render = True + + def create_base_object(self): + """Add placeholder object to assign GeoNode Modifier to.""" + # Setup a blender collection + collection = utility.create_collection(self.config["name"]) + utility.set_active_collection(collection) + + # Setup GeoNodes + bpy.ops.mesh.primitive_plane_add() + bpy.context.active_object.name = self.config["name"] + self.crop = utility.ObjPointer(bpy.context.active_object) + + def configure(self): + """Apply configuration for current frame.""" + # Refresh References + self.geo_node_modifier = self.crop.get().modifiers[self.config["name"]] + # Configure settings from config + self.configure_settings() + for obj in self.instance_objects.get().objects: + utility.add_volume_attribute(obj) + logging.info("Crop: {0} configured".format(self.config["name"])) diff --git a/syclops/blender/plugins/plugin_data/crop.blend b/syclops/blender/plugins/plugin_data/crop.blend new file mode 100644 index 0000000..ef6efe2 Binary files /dev/null and b/syclops/blender/plugins/plugin_data/crop.blend differ diff --git a/syclops/blender/plugins/plugin_data/simulated_scatter.blend b/syclops/blender/plugins/plugin_data/simulated_scatter.blend new file mode 100644 index 0000000..339064e Binary files /dev/null and b/syclops/blender/plugins/plugin_data/simulated_scatter.blend differ diff --git a/syclops/blender/plugins/schema/crop.schema.yaml b/syclops/blender/plugins/schema/crop.schema.yaml new file mode 100644 index 0000000..f1ad84b --- /dev/null +++ b/syclops/blender/plugins/schema/crop.schema.yaml @@ -0,0 +1,66 @@ +description: Scatters 3D models on a floor object in a grid pattern. +type: array +items: + type: object + properties: + name: + description: Unique identifier of the plugin + type: string + models: + description: 3D assets to scatter. + oneOf: + - type: array + items: + $ref: "#/definitions/asset_models" + - $ref: "#/definitions/asset_models" + floor_object: + description: Name of the floor object to scatter on. + type: string + max_texture_size: + description: Maximum texture size in pixel. Will reduce the texture to save GPU RAM. + type: integer + density_map: + description: Texture that alters the density. It is normalized to 0-1. + $ref: "#/definitions/image_texture_evaluation" + decimate_mesh_factor: + description: Factor between 0-1 that decimates the number of vertices of the mesh. Lower means less vertices. + type: number + scale_standard_deviation: + description: Scale variance of the scattered objects. + $ref: "#/definitions/number_evaluation" + class_id: + description: Class ID for ground truth output. + type: integer + class_id_offset: + description: Class ID offset for ground truth output. The keys are the name of the material and the values are the offsets. + type: object + additionalProperties: + type: integer + crop_angle: + description: Global orientation of the row direction in degrees. + $ref: "#/definitions/number_evaluation" + row_distance: + description: Distance between rows in meters. + $ref: "#/definitions/number_evaluation" + row_standard_deviation: + description: Standard deviation of the row distance in meters. + $ref: "#/definitions/number_evaluation" + plant_distance: + description: Intra row distance between plants in meters. + $ref: "#/definitions/number_evaluation" + plant_standard_deviation: + description: Standard deviation of the intra row distance in meters. + $ref: "#/definitions/number_evaluation" + + required: + [ + name, + models, + floor_object, + scale_standard_deviation, + row_distance, + plant_distance, + crop_angle, + row_standard_deviation, + plant_standard_deviation, + ] diff --git a/syclops/blender/plugins/schema/simulated_scatter.schema.yaml b/syclops/blender/plugins/schema/simulated_scatter.schema.yaml new file mode 100644 index 0000000..dd1646b --- /dev/null +++ b/syclops/blender/plugins/schema/simulated_scatter.schema.yaml @@ -0,0 +1,50 @@ +description: Scatters 3D assets on a floor object and simulates physics to drop them on the surface. +type: array +items: + type: object + properties: + name: + description: Unique identifier of the plugin + type: string + models: + description: 3D assets to scatter. + oneOf: + - type: array + items: + $ref: "#/definitions/asset_models" + - $ref: "#/definitions/asset_models" + floor_object: + description: Name of the floor object to scatter on. + type: string + max_texture_size: + description: Maximum texture size in pixel. Will reduce the texture to save GPU RAM. + type: integer + decimate_mesh_factor: + description: Factor between 0-1 that decimates the number of vertices of the mesh. Lower means less vertices. + type: number + density: + description: Density of objects per square meter. + type: number + density_texture: + description: Texture that alters the density per pixel. Needs to be a single channel image that is normalized to 0-1. + $ref: "#/definitions/image_texture_evaluation" + scale_std: + description: Standard deviation of the scale randomization. + type: number + convex_decomposition_quality: + description: Quality setting for the convex decomposition. Higher means more accurate but slower. Range 1-100. + type: integer + simulation_steps: + description: Number of simulation steps to run. + type: integer + + required: + [ + name, + models, + floor_object, + density, + scale_std, + convex_decomposition_quality, + simulation_steps, + ] diff --git a/syclops/blender/plugins/simulated_scatter.py b/syclops/blender/plugins/simulated_scatter.py new file mode 100644 index 0000000..b4a8320 --- /dev/null +++ b/syclops/blender/plugins/simulated_scatter.py @@ -0,0 +1,367 @@ +import logging + +import bpy +import numpy as np +from mathutils import Vector +from syclops import utility +from syclops.blender.plugins.plugin_interface import PluginInterface + + +class SimulatedScatter(PluginInterface): + """ + Plugin scatters objects and drops them on a given floor object. + """ + + def __init__(self, config: dict): + self.floor_objects: list[utility.ObjPointer] = None + self.conv_hull_instances: list[utility.ObjPointer] = [] + self.conv_hull_instances_collection: utility.ObjPointer = None + self.instanced_conv_hulls: list[utility.ObjPointer] = [] + super().__init__(config) + + def load(self): + self._load_instance_objects() + self._load_floor_object() + logging.info("Calculating Spawn Points") + scatter_points = self._calc_scatter_points() + logging.info("Running Physics Simulation") + self._simulate_convex_objects(scatter_points) + + def _simulate_convex_objects(self, scatter_points: np.array): + obj_poses = {} + with utility.RevertAfter(): + bpy.ops.rigidbody.world_add() + collection_rigidbody = bpy.data.collections.new("Rigidbody") + bpy.data.scenes["Scene"].rigidbody_world.collection = collection_rigidbody + for scatter_point in scatter_points: + new_conv_hulls = [] + # Select random convex hull + parent_uuid = np.random.choice(self.conv_hull_instances).get()[ + "PARENT_UUID" + ] + conv_hulls = utility.filter_objects("PARENT_UUID", parent_uuid) + random_rotation = Vector(np.random.uniform(0, 2 * np.pi, size=3)) + random_scale_value = ( + np.random.normal(1, self.config["scale_std"]) + if "scale_std" in self.config + else 1 + ) + random_scale = Vector([random_scale_value] * 3) + for conv_hull in conv_hulls: + new_conv_hull = conv_hull.copy() + new_conv_hull.data = conv_hull.data.copy() + new_conv_hull.location = scatter_point + new_conv_hull.rotation_euler = random_rotation + new_conv_hull.scale = random_scale + self.conv_hull_instances_collection.get().objects.link( + new_conv_hull + ) + new_conv_hull["PARENT_UUID_COPY"] = conv_hull["PARENT_UUID"] + del new_conv_hull["UUID"] + del new_conv_hull["PARENT_UUID"] + self.instanced_conv_hulls.append(utility.ObjPointer(new_conv_hull)) + # Delete Parent UUID to prevent further copying + bpy.data.scenes["Scene"].rigidbody_world.collection.objects.link( + new_conv_hull + ) + new_conv_hull.rigid_body.type = "ACTIVE" + new_conv_hull.rigid_body.friction = 10 + new_conv_hull.select_set(True) + new_conv_hulls.append(new_conv_hull) + if len(new_conv_hulls) > 1: + bpy.ops.object.select_all(action="DESELECT") + for new_conv_hull in new_conv_hulls: + new_conv_hull.select_set(True) + bpy.context.view_layer.objects.active = new_conv_hulls[0] + bpy.ops.rigidbody.connect() + for obj in self.floor_objects: + convex_hulls = utility.convex_decomposition( + obj, + self.conv_hull_instances_collection, + self.config["convex_decomposition_quality"], + ) + for convex_hull in convex_hulls: + bpy.data.scenes["Scene"].rigidbody_world.collection.objects.link( + convex_hull + ) + convex_hull.rigid_body.type = "PASSIVE" + convex_hull.rigid_body.friction = 10 + + for i in range(self.config["simulation_steps"]): + logging.info(f"Simulation Step: {i}") + bpy.context.scene.frame_set(i + 1) + + disSet = utility.DisjointSet() + if bpy.data.scenes["Scene"].rigidbody_world.constraints: + for constraint in bpy.data.scenes[ + "Scene" + ].rigidbody_world.constraints.objects: + obj1 = constraint.rigid_body_constraint.object1 + obj2 = constraint.rigid_body_constraint.object2 + if obj1 and obj2: + disSet.union(obj1, obj2) + + processed_clusters = [] + + # Iterate over all instanced convex hulls + for obj in self.instanced_conv_hulls: + obj = obj.get() + if disSet.find_cluster(obj) in processed_clusters: + continue + if disSet.find_cluster(obj): + processed_clusters.append(disSet.find_cluster(obj)) + + # Write pose to dictionary + parent_uuid = obj["PARENT_UUID_COPY"] + if parent_uuid in obj_poses: + # Check if current pose is already in the pose list + current_pose = obj.matrix_world.copy() + if current_pose in obj_poses[parent_uuid]: + pass + else: + obj_poses[parent_uuid].append(current_pose) + else: + # Create new list for object + obj_poses[parent_uuid] = [obj.matrix_world.copy()] + + # Set the object poses + # Create new collection and assign a pointer + final_collection = utility.create_collection(self.config["name"] + "_Final") + + for parent_uuid, poses in obj_poses.items(): + parent_obj = utility.filter_objects("UUID", parent_uuid)[0] + for pose in poses: + # Creater instance object + instance_object = parent_obj.copy() + instance_object.data = parent_obj.data.copy() + # Add instance object to collection + final_collection.objects.link(instance_object) + # Set instance object pose + instance_object.matrix_world = pose + + self._add_volume_attribute(final_collection) + + # Delete self.conv_hull_instances + for obj in self.conv_hull_instances: + bpy.data.objects.remove(obj.get(), do_unlink=True) + self.conv_hull_instances = [] + bpy.data.collections.remove(self.conv_hull_instances_collection.get()) + self.conv_hull_instances_collection = None + + def _create_base_object(self): + """Add placeholder object to assign GeoNode Modifier to""" + # Setup a blender collection + _coll = utility.create_collection(self.config["name"]) + + utility.set_active_collection(_coll) + + # Setup GeoNodes + bpy.ops.mesh.primitive_plane_add() + bpy.context.active_object.name = self.config["name"] + self.scatter_geo_node_obj = utility.ObjPointer(bpy.context.active_object) + + def _load_instance_objects(self): + # Create new collection and assign a pointer + self.instance_objects = utility.ObjPointer( + utility.create_collection(self.config["name"] + "_Objs") + ) + utility.set_active_collection(self.instance_objects.get()) + + # Import the geometry + loaded_objs = utility.import_assets(self.config["models"]) + loaded_objs_pointer = [utility.ObjPointer(obj) for obj in loaded_objs] + self.conv_hull_instances_collection = utility.ObjPointer( + utility.create_collection(self.config["name"] + "_ConvHulls") + ) + for obj_pointer in loaded_objs_pointer: + self.reduce_size(obj_pointer.get()) + obj_pointer.get().hide_set(True) + self.write_config(obj_pointer.get()) + conv_hulls = utility.convex_decomposition( + obj_pointer, + self.conv_hull_instances_collection, + self.config["convex_decomposition_quality"], + ) + self.conv_hull_instances.extend( + [utility.ObjPointer(obj) for obj in conv_hulls] + ) + self.instance_objects.get().hide_render = True + + def _load_floor_object(self): + # Create new collection and assign a pointer + floor_object_name = self.config["floor_object"] + floor_objects = utility.filter_objects("name", floor_object_name) + self.floor_objects = [utility.ObjPointer(obj) for obj in floor_objects] + + def _calc_scatter_points(self): + """Scatter points inside the floor bounding box with a minimum distance of the biggest radius. + Scatter points in accordance to the density specified in the config. + """ + floor_bbox_x, floor_bbox_y, height = self._get_floor_bbox() + # Calculate the minimum distance between scatter points + min_distance = self._calc_biggest_bbox_radius() + # Number of objects to scatter inside the rectangle in accordance to the density + num_objects = int( + (floor_bbox_x[1] - floor_bbox_x[0]) + * (floor_bbox_y[1] - floor_bbox_y[0]) + * self.config["density"] + ) + + # Randomly scatter points inside the rectangle with a minimum distance of the biggest radius + points = self._grid_points_in_rectangle( + floor_bbox_x, floor_bbox_y, min_distance + ) + points = self._remove_points_outside_floor(points) + points = self._remove_if_too_many_points(points, num_objects) + points = self._shift_points_above_floor(height, min_distance, points) + points = self._layer_points(points, num_objects, min_distance) + if "density_texture" in self.config: + points = self._apply_density_texture(points, floor_bbox_x, floor_bbox_y) + points = self._add_position_jitter(points, min_distance) + return points + + def _get_floor_bbox(self): + """Returns the x and y points of the floor bounding box and the height of the floor""" + x = [] + y = [] + height = 0 + for obj in self.floor_objects: + bbox = obj.get().bound_box + x.extend([point[0] for point in bbox]) + y.extend([point[1] for point in bbox]) + height = max(height, max([point[2] for point in bbox])) + return (min(x), max(x)), (min(y), max(y)), height + + def _calc_biggest_bbox_radius(self): + """Calculates the biggest radius from an object center to a corner""" + biggest_bbox = 0 + for obj in self.instance_objects.get().objects: + bbox = self._calc_bbox_radius(obj) + biggest_bbox = max(biggest_bbox, bbox) + return biggest_bbox + + def _calc_bbox_radius(self, obj: bpy.types.Object): + """Calculates the radius from an object origin to a corner""" + bbox = obj.bound_box + origin = obj.matrix_world @ obj.location + distances_to_origin = [ + origin - obj.matrix_world @ Vector(corner) for corner in bbox + ] + return max([dist.length for dist in distances_to_origin]) + + def _grid_points_in_rectangle(self, floor_bbox_x, floor_bbox_y, min_distance): + x_min, x_max = floor_bbox_x + y_min, y_max = floor_bbox_y + min_distance = min(min_distance, (x_max - x_min) / 2) + min_distance = min(min_distance, (y_max - y_min) / 2) + x = np.arange(x_min, x_max, min_distance) + y = np.arange(y_min, y_max, min_distance) + points = np.array(np.meshgrid(x, y)).T.reshape(-1, 2) + return points + + def _remove_if_too_many_points(self, points, num_objects): + # Randomly delete points if there are too many + if points.shape[0] > num_objects: + points = np.delete( + points, + np.random.choice( + points.shape[0], points.shape[0] - num_objects, replace=False + ), + axis=0, + ) + return points + + def _remove_points_outside_floor(self, points: np.array): + """Cast a ray from a point and check if floor geometry is hit + + Args: + points (np.array): Array of points to check + + Returns: + np.array: Array of points that are above the floor geometry + """ + direction = Vector((0, 0, -1)) + new_points = [] + for point in points: + bpy_point = Vector((point[0], point[1], 1000)) + hit = False + for obj in self.floor_objects: + obj = obj.get() + hit, _, _, _ = obj.ray_cast(bpy_point, direction) + if hit: + new_points.append(point) + break + return np.array(new_points) + + def _shift_points_above_floor(self, height, min_distance, points): + return np.hstack( + (points, np.ones((points.shape[0], 1)) * height + min_distance * 2) + ) + + def _layer_points(self, points, num_objects, min_distance): + """Layers points to reach desired number of objects + + Args: + points (np.array): Points to layer + num_objects (int): Number of objects to reach + + Returns: + np.array: Layed out points + """ + # If the number of points is already higher than the desired number of objects, return the points + if points.shape[0] >= num_objects: + return points + # Calculate the number of points to add + num_points_to_add = num_objects - points.shape[0] + # Calculate the number of layers needed + num_layers = int(np.ceil(num_points_to_add / points.shape[0])) + # Add layers with additions height of the minimum distance + original_points = points.copy() + changed_height_vector = np.array([0, 0, min_distance]) + for i in range(num_layers - 1): + points = np.vstack( + (points, original_points + changed_height_vector * (i + 1)) + ) + return points + + def _apply_density_texture(self, points, floor_bbox_x, floor_bbox_y): + """Load a density texture and apply it to the points""" + image_asset = utility.eval_param(self.config["density_texture"]) + root_path, texture_path = utility.get_asset_path(image_asset) + + image = utility.load_img_as_array(str(root_path / texture_path)) + img_x, img_y = image.shape[1], image.shape[0] + # Check which side of floor is bigger + if floor_bbox_x[1] - floor_bbox_x[0] > floor_bbox_y[1] - floor_bbox_y[0]: + offset_x = floor_bbox_x[0] + offset_y = floor_bbox_y[0] + scale_x = (floor_bbox_x[1] - floor_bbox_x[0]) / img_x + scale_y = (floor_bbox_x[1] - floor_bbox_x[0]) / img_y + else: + offset_y = floor_bbox_y[0] + offset_x = floor_bbox_x[0] + scale_y = (floor_bbox_y[1] - floor_bbox_y[0]) / img_y + scale_x = (floor_bbox_y[1] - floor_bbox_y[0]) / img_x + # Get pixel values of the points + pixel_coords = np.array( + [(points[:, 0] - offset_x) / scale_x, (points[:, 1] - offset_y) / scale_y] + ).T + pixel_values = utility.interpolate_img(image[:, :, 0], pixel_coords) + # Remove points by probability + points = points[pixel_values > np.random.uniform(size=pixel_values.shape[0])] + return points + + def _add_position_jitter(self, points, min_distance): + """Add jitter to each point""" + jitter = np.random.uniform(-min_distance, min_distance, size=points.shape) + points += jitter + return points + + def _add_volume_attribute(self, collection: bpy.types.Collection): + """Add volume attribute to each object in the instance.""" + for obj in collection.objects: + utility.add_volume_attribute(obj) + + def configure(self): + """Apply configuration for current frame""" + logging.info("Simulated Scatter: %s configured", self.config["name"]) diff --git a/syclops/utility/blender_utils.py b/syclops/utility/blender_utils.py index e31c0b4..31efb1a 100644 --- a/syclops/utility/blender_utils.py +++ b/syclops/utility/blender_utils.py @@ -76,7 +76,15 @@ def apply_modifiers(target_object: bpy.types.Object) -> None: logging.info(info_msg) target_object.modifiers.remove(modifier) else: - bpy.ops.object.modifier_apply(context, modifier=modifier.name) + try: + bpy.ops.object.modifier_apply(context, modifier=modifier.name) + except RuntimeError: + info_msg = "Error applying {0} to {1}, removing it instead.".format( + modifier.name, + target_object.name, + ) + logging.info(info_msg) + target_object.modifiers.remove(modifier) # Clean up remaining modifiers for modifier in target_object.modifiers: