Cree datos sintéticos para canalizaciones de visión artificial en AWS

Recopilar y anotar datos de imágenes es una de las tareas que más recursos requiere en cualquier proyecto de visión artificial. Puede llevar meses recopilar, analizar y experimentar completamente con secuencias de imágenes al nivel que necesita para competir en el mercado actual. Incluso después de haber recopilado correctamente los datos, sigue teniendo un flujo constante de errores de anotación, imágenes mal enmarcadas, pequeñas cantidades de datos significativos en un mar de capturas no deseadas y más. Estos cuellos de botella importantes son la razón por la cual la creación de datos sintéticos debe estar en el conjunto de herramientas de todos los ingenieros modernos. Al crear representaciones en 3D de los objetos que queremos modelar, podemos crear prototipos de algoritmos rápidamente mientras recopilamos datos en vivo al mismo tiempo.

En esta publicación, lo guío a través de un ejemplo del uso de la biblioteca de animación de código abierto Blender para construir una canalización de datos sintéticos de extremo a extremo, utilizando como ejemplo los nuggets de pollo. La siguiente imagen es una ilustración de los datos generados en esta publicación de blog.

¿Qué es la licuadora?

Licuadora es un software de gráficos 3D de código abierto que se utiliza principalmente en animación, impresión 3D y realidad virtual. Tiene un conjunto de rigging, animación y simulación extremadamente completo que permite la creación de mundos 3D para casi cualquier caso de uso de visión por computadora. También tiene una comunidad de soporte extremadamente activa donde se resuelven la mayoría, si no todos, los errores de los usuarios.

Configura tu entorno local

Instalamos dos versiones de Blender: una en una máquina local con acceso a una GUI y la otra en una instancia P2 de Amazon Elastic Compute Cloud (Amazon EC2).

Instalar Blender y ZPY

Instalar Blender desde el Sitio web de la licuadora.

Luego complete los siguientes pasos:

  1. Ejecute los siguientes comandos:
    wget https://mirrors.ocf.berkeley.edu/blender/release/Blender3.2/blender-3.2.0-linux-x64.tar.xz
    sudo tar -Jxf blender-3.2.0-linux-x64.tar.xz --strip-components=1 -C /bin
    rm -rf blender*
    
    /bin/3.2/python/bin/python3.10 -m ensurepip
    /bin/3.2/python/bin/python3.10 -m pip install --upgrade pip
  2. Copie los encabezados de Python necesarios en la versión de Blender de Python para que pueda usar otras bibliotecas que no sean de Blender:
    wget https://www.python.org/ftp/python/3.10.2/Python-3.10.2.tgz
    tar -xzf Python-3.10.2.tgz
    sudo cp Python-3.10.2/Include/* /bin/3.2/python/include/python3.10
  3. Anule su versión de Blender y fuerce las instalaciones para que Python proporcionado por Blender funcione:
    /bin/3.2/python/bin/python3.10 -m pip install pybind11 pythran Cython numpy==1.22.1
    sudo /bin/3.2/python/bin/python3.10 -m pip install -U Pillow --force
    sudo /bin/3.2/python/bin/python3.10 -m pip install -U scipy --force
    sudo /bin/3.2/python/bin/python3.10 -m pip install -U shapely --force
    sudo /bin/3.2/python/bin/python3.10 -m pip install -U scikit-image --force
    sudo /bin/3.2/python/bin/python3.10 -m pip install -U gin-config --force
    sudo /bin/3.2/python/bin/python3.10 -m pip install -U versioneer --force
    sudo /bin/3.2/python/bin/python3.10 -m pip install -U shapely --force
    sudo /bin/3.2/python/bin/python3.10 -m pip install -U ptvsd --force
    sudo /bin/3.2/python/bin/python3.10 -m pip install -U ptvseabornsd --force
    sudo /bin/3.2/python/bin/python3.10 -m pip install -U zmq --force
    sudo /bin/3.2/python/bin/python3.10 -m pip install -U pyyaml --force
    sudo /bin/3.2/python/bin/python3.10 -m pip install -U requests --force
    sudo /bin/3.2/python/bin/python3.10 -m pip install -U click --force
    sudo /bin/3.2/python/bin/python3.10 -m pip install -U table-logger --force
    sudo /bin/3.2/python/bin/python3.10 -m pip install -U tqdm --force
    sudo /bin/3.2/python/bin/python3.10 -m pip install -U pydash --force
    sudo /bin/3.2/python/bin/python3.10 -m pip install -U matplotlib --force
  4. Descargar zpy e instalar desde la fuente:
    git clone https://github.com/ZumoLabs/zpy
    cd zpy
    vi requirements.txt
  5. Cambia la versión de NumPy a >=1.19.4 y scikit-image>=0.18.1 para hacer la instalación en 3.10.2 posible y para que no obtenga ninguna sobrescritura:
    numpy>=1.19.4
    gin-config>=0.3.0
    versioneer
    scikit-image>=0.18.1
    shapely>=1.7.1
    ptvsd>=4.3.2
    seaborn>=0.11.0
    zmq
    pyyaml
    requests
    click
    table-logger>=0.3.6
    tqdm
    pydash
  6. Para garantizar la compatibilidad con Blender 3.2, vaya a zpy/render.py y comente las siguientes dos líneas (para obtener más información, consulte Blender 3.0 Falla #54):
    #scene.render.tile_x = tile_size
    #scene.render.tile_y = tile_size
  7. A continuación, instale el zpy biblioteca:
    /bin/3.2/python/bin/python3.10 setup.py install --user
    /bin/3.2/python/bin/python3.10 -c "import zpy; print(zpy.__version__)"
  8. Descargue la versión de complementos de zpy desde el repositorio de GitHub para que pueda ejecutar activamente su instancia:
    cd ~
    curl -O -L -C - "https://github.com/ZumoLabs/zpy/releases/download/v1.4.1rc9/zpy_addon-v1.4.1rc9.zip"
    sudo unzip zpy_addon-v1.4.1rc9.zip -d /bin/3.2/scripts/addons/
    mkdir .config/blender/
    mkdir .config/blender/3.2
    mkdir .config/blender/3.2/scripts
    mkdir .config/blender/3.2/scripts/addons/
    mkdir .config/blender/3.2/scripts/addons/zpy_addon/
    sudo cp -r zpy/zpy_addon/* .config/blender/3.2/scripts/addons/zpy_addon/
  9. Guarda un archivo llamado enable_zpy_addon.py en tus /home directorio y ejecute el comando de habilitación, porque no tiene una GUI para activarlo:
    import bpy, os
    p = os.path.abspath('zpy_addon-v1.4.1rc9.zip')
    bpy.ops.preferences.addon_install(overwrite=True, filepath=p)
    bpy.ops.preferences.addon_enable(module="zpy_addon")
    bpy.ops.wm.save_userpref()
    
    sudo blender -b -y --python enable_zpy_addon.py

    Si zpy-addon no se instala (por el motivo que sea), puede instalarlo a través de la GUI.

  10. En Blender, en el Editar menú, elige preferencias.
  11. Elegir Complementos en el panel de navegación y activar zpy.

Debería ver una página abierta en la GUI y podrá elegir ZPY. Esto confirmará que Blender está cargado.

AliceVision y Meshroom

Instale AliceVision y Meshroom desde sus respectivos repositorios de GitHub:

MPEG

Su sistema debe tener ffmpegpero si no es así, deberá descargar eso.

Mallas instantáneas

Puede compilar la biblioteca usted mismo o descargar los binarios precompilados disponibles (que es lo que hice) para Mallas instantáneas.

Configure su entorno de AWS

Ahora configuramos el entorno de AWS en una instancia de EC2. Repetimos los pasos del apartado anterior, pero solo para Blender y zpy.

  1. En la consola de Amazon EC2, elija Instancias de lanzamiento.
  2. Elija su AMI. Hay algunas opciones desde aquí. Podemos elegir una imagen estándar de Ubuntu, elegir una instancia de GPU y luego instalar manualmente los controladores y configurar todo, o podemos tomar la ruta fácil y comenzar con una AMI de aprendizaje profundo preconfigurada y solo preocuparnos por instalar Blender. Para esto publicación, uso la segunda opción y elijo la última versión de Deep Learning AMI para Ubuntu (Versión de AMI de aprendizaje profundo (Ubuntu 18.04) 61.0).
  3. Para Tipo de instanciaelegir p2.xgrande.
  4. Si no tiene un par de claves, cree uno nuevo o elija uno existente.
  5. Para esta publicación, use la configuración predeterminada para la red y el almacenamiento.
  6. Elegir Instancias de lanzamiento.
  7. Elegir Conectar y encuentre las instrucciones para iniciar sesión en nuestra instancia desde SSH en el cliente SSH pestaña.
  8. Conéctese con SSH: ssh -i "your-pem" ubuntu@IPADDRESS.YOUR-REGION.compute.amazonaws.com

Una vez que se haya conectado a su instancia, siga los mismos pasos de instalación de la sección anterior para instalar Blender y zpy.

Recopilación de datos: escaneo 3D de nuestra pepita

Para este paso, uso un iPhone para grabar un video de 360 ​​grados a un ritmo bastante lento alrededor de mi nugget. Pegué un nugget de pollo en un palillo y pegué el palillo a mi encimera, y simplemente giré mi cámara alrededor del nugget para obtener tantos ángulos como pude. Cuanto más rápido filme, menos probable es que obtenga buenas imágenes con las que trabajar dependiendo de la velocidad de obturación.

Después de que terminé de filmar, envié el video a mi correo electrónico y extraje el video a una unidad local. A partir de ahí, usé ffmepg para cortar el video en marcos para hacer que la ingestión de Meshroom sea mucho más fácil:

mkdir nugget_images
ffmpeg -i VIDEO.mov ffmpeg nugget_images/nugget_%06d.jpg

Abra Meshroom y use la GUI para arrastrar el nugget_images carpeta al panel de la izquierda. A partir de ahí, elige comienzo y espere unas horas (o menos) dependiendo de la duración del video y si tiene una máquina habilitada para CUDA.

Debería ver algo como la siguiente captura de pantalla cuando esté casi completo.

Recopilación de datos: manipulación de Blender

Cuando nuestra reconstrucción de Meshroom esté completa, complete los siguientes pasos:

  1. Abra la GUI de Blender y en el Expediente menú, elige Importarentonces escoge Frente de onda (.obj) a su archivo de textura creado desde Meshroom.
    El archivo debe guardarse en path/to/MeshroomCache/Texturing/uuid-string/texturedMesh.obj.
  2. Cargue el archivo y observe la monstruosidad que es su objeto 3D.

    Aquí es donde se pone un poco complicado.
  3. Desplácese hasta la parte superior derecha y elija el Estructura alámbrica icono en Sombreado de ventana gráfica.
  4. Seleccione su objeto en la ventana de visualización derecha y asegúrese de que esté resaltado, desplácese hasta la ventana de visualización de diseño principal y presione Pestaña o elegir manualmente Modo de edición.
  5. Luego, maniobre la ventana de visualización de tal manera que le permita ver su objeto con la menor distancia posible detrás de él. Tendrás que hacer esto varias veces para que sea realmente correcto.
  6. Haga clic y arrastre un cuadro delimitador sobre el objeto para que solo se resalte la pepita.
  7. Después de que se resalte como en la siguiente captura de pantalla, separamos nuestra pepita de la masa 3D haciendo clic con el botón izquierdo, eligiendo Separadoy entonces Selección.

    Ahora nos movemos hacia la derecha, donde deberíamos ver dos objetos texturizados: texturedMesh y texturedMesh.001.
  8. Nuestro nuevo objeto debe ser texturedMesh.001así que elegimos texturedMesh y elige Borrar para eliminar la masa no deseada.
  9. Elige el objeto (texturedMesh.001) a la derecha, muévase a nuestro visor y elija el objeto, Establecer origeny Origen al centro de masa.

Ahora, si queremos, podemos mover nuestro objeto al centro de la ventana gráfica (o simplemente dejarlo donde está) y verlo en todo su esplendor. ¡Observe el gran agujero negro del que realmente no obtuvimos una buena cobertura de la película! Vamos a tener que corregir esto.

Para limpiar nuestro objeto de cualquier impureza de píxeles, exportamos nuestro objeto a un archivo .obj. Asegúrate de elegir Solo selección al exportar.

Recopilación de datos: limpieza con mallas instantáneas

Ahora tenemos dos problemas: nuestra imagen tiene una brecha de píxeles creada por nuestra mala filmación que debemos limpiar, y nuestra imagen es increíblemente densa (lo que hará que la generación de imágenes lleve mucho tiempo). Para abordar ambos problemas, necesitamos usar un software llamado Instant Meshes para extrapolar nuestra superficie de píxeles para cubrir el agujero negro y también para reducir el objeto total a un tamaño más pequeño y menos denso.

  1. Abra Instant Meshes y cargue nuestro recientemente guardado nugget.obj expediente.
  2. Por debajo Campo de orientaciónelegir Resolver.
  3. Por debajo Campo de posiciónelegir Resolver.
    Aquí es donde se pone interesante. Si explora su objeto y nota que las líneas entrecruzadas del solucionador de posición parecen inconexas, puede elegir el ícono de peine debajo Campo de orientación y vuelva a dibujar las líneas correctamente.
  4. Elegir Resolver para ambos Campo de orientación y Campo de posición.
  5. Si todo se ve bien, exporte la malla, asígnele un nombre como nugget_refined.objy guárdelo en el disco.

Recopilación de datos: ¡Agitar y hornear!

Debido a que nuestra malla de baja poli no tiene ninguna textura de imagen asociada y nuestra malla de alta poli sí la tiene, necesitamos hornear la textura de alta poli en la malla de baja poli, o crear una nueva textura y asignarla a nuestro objeto En aras de la simplicidad, vamos a crear una textura de imagen desde cero y aplicarla a nuestra pepita.

Usé la búsqueda de imágenes de Google para nuggets y otras cosas fritas para obtener una imagen de alta resolución de la superficie de un objeto frito. Encontré una imagen de súper alta resolución de una cuajada de queso frito e hice una nueva imagen llena de la textura frita.

Con esta imagen, estoy listo para completar los siguientes pasos:

  1. Abra Blender y cargue el nuevo nugget_refined.obj de la misma manera que cargó su objeto inicial: en el Expediente menú, elige Importar, Frente de onda (.obj)y elija el nugget_refined.obj expediente.
  2. A continuación, vaya a la Sombreado pestaña.
    En la parte inferior, debe notar dos cuadros con los títulos. Principios BDSF y Salida de materiales.
  3. Sobre el Agregar menú, elige Textura y Textura de imagen.
    Un Textura de imagen debe aparecer el cuadro.
  4. Elegir Abrir imagen y carga tu imagen de textura frita.
  5. Arrastra el ratón entre Color en el Textura de imagen caja y Color de base en el Principios BDSF caja.

¡Ahora tu pepita debería estar lista para salir!

Recopilación de datos: crear variables de entorno de Blender

Ahora que tenemos nuestro objeto nugget base, necesitamos crear algunas colecciones y variables de entorno para ayudarnos en nuestro proceso.

  1. Haga clic con el botón izquierdo en el área de la escena de la mano y elija Nueva colección.
  2. Crea las siguientes colecciones: ANTECEDENTES, PEPITAy GENERADO.
  3. Arrastre la pepita a la PEPITA colección y cambiarle el nombre nugget_base.

Recopilación de datos: crear un avión

Vamos a crear un objeto de fondo a partir del cual se generarán nuestros nuggets cuando rendericemos imágenes. En un caso de uso del mundo real, este plano es donde se colocan nuestras pepitas, como una bandeja o contenedor.

  1. Sobre el Agregar menú, elige Malla y entonces Plano.
    Desde aquí, nos movemos hacia el lado derecho de la página y encontramos el cuadro naranja (Propiedades del objeto).
  2. En el Transformar panel, para XYZ Eulerestablecer X a 46.968, Y a 46.968, y Z a 1.0.
  3. Para ambos Ubicación y Rotaciónestablecer X, Yy Z a 0.

Recopilación de datos: configure la cámara y el eje

A continuación, configuraremos nuestras cámaras correctamente para que podamos generar imágenes.

  1. Sobre el Agregar menú, elige Vacío y eje llano.
  2. Nombra el objeto Eje principal.
  3. Asegúrate de que nuestro eje sea 0 para todas las variables (de modo que esté directamente en el centro).
  4. Si ya ha creado una cámara, arrástrela hasta debajo del eje principal.
  5. Elegir Artículo y Transformar.
  6. Para Ubicaciónestablecer X a 0, Y a 0, y Z a 100.

Recolección de datos: Aquí viene el sol

A continuación, agregamos un objeto Sol.

  1. Sobre el Agregar menú, elige Luz y Sol.
    La ubicación de este objeto no importa necesariamente siempre que esté centrado en algún lugar sobre el objeto plano que hemos establecido.
  2. Elija el ícono de la bombilla verde en el panel inferior derecho (Propiedades de datos de objetos) y establezca la intensidad en 5,0.
  3. Repita el mismo procedimiento para agregar un Luz objeto y colóquelo en un lugar aleatorio sobre el avión.

Recopilación de datos: Descargar fondos aleatorios

Para inyectar aleatoriedad en nuestras imágenes, descargamos tantas texturas aleatorias de textura.ninja como podamos (por ejemplo, ladrillos). Descargue a una carpeta dentro de su espacio de trabajo llamada random_textures. Descargué unos 50.

Generar imágenes

Ahora llegamos a lo divertido: generar imágenes.

Pipeline de generación de imágenes: Object3D y DensityController

Comencemos con algunas definiciones de código:

class Object3D:
	'''
	object container to store mesh information about the
	given object

	Returns
	the Object3D object
	'''
	def __init__(self, object: Union[bpy.types.Object, str]):
		"""Creates a Object3D object.

		Args:
		obj (Union[bpy.types.Object, str]): Scene object (or it's name)
		"""
		self.object = object
		self.obj_poly = None
		self.mat = None
		self.vert = None
		self.poly = None
		self.bvht = None
		self.calc_mat()
		self.calc_world_vert()
		self.calc_poly()
		self.calc_bvht()

	def calc_mat(self) -> None:
		"""store an instance of the object's matrix_world"""
		self.mat = self.object.matrix_world

	def calc_world_vert(self) -> None:
		"""calculate the verticies from object's matrix_world perspective"""
		self.vert = [self.mat @ v.co for v in self.object.data.vertices]
		self.obj_poly = np.array(self.vert)

	def calc_poly(self) -> None:
		"""store an instance of the object's polygons"""
		self.poly = [p.vertices for p in self.object.data.polygons]

	def calc_bvht(self) -> None:
		"""create a BVHTree from the object's polygon"""
		self.bvht = BVHTree.FromPolygons( self.vert, self.poly )

	def regenerate(self) -> None:
		"""reinstantiate the object's variables;
		used when the object is manipulated after it's creation"""
		self.calc_mat()
		self.calc_world_vert()
		self.calc_poly()
		self.calc_bvht()

	def __repr__(self):
		return "Object3D: " + self.object.__repr__()

Primero definimos una clase contenedora básica con algunas propiedades importantes. Esta clase existe principalmente para permitirnos crear un árbol BVH (una forma de representar nuestro objeto nugget en el espacio 3D), donde necesitaremos usar el BVHTree.overlap método para ver si dos objetos nugget generados independientes se superponen en nuestro espacio 3D. Más sobre esto más adelante.

La segunda pieza de código es nuestro controlador de densidad. Esto sirve como una forma de atarnos a las reglas de la realidad y no al mundo 3D. Por ejemplo, en el mundo 3D de Blender, los objetos en Blender pueden existir unos dentro de otros; sin embargo, a menos que alguien esté realizando alguna ciencia extraña en nuestros nuggets de pollo, queremos asegurarnos de que no haya dos nuggets superpuestos en un grado que lo haga visualmente poco realista.

Usamos nuestro Plane objeto para generar un conjunto de cubos invisibles delimitados que se pueden consultar en cualquier momento para ver si el espacio está ocupado o no.


Ver el siguiente código:

class DensityController:
    """Container that controlls the spacial relationship between 3D objects

    Returns:
        DensityController: The DensityController object.
    """
    def __init__(self):
        self.bvhtrees = None
        self.overlaps = None
        self.occupied = None
        self.unoccupied = None
        self.objects3d = []

    def auto_generate_kdtree_cubes(
        self,
        num_objects: int = 100, # max size of nuggets
    ) -> None:
        """
        function to generate physical kdtree cubes given a plane of -resize- size
        this allows us to access each cube's overlap/occupancy status at any given
        time
        
        creates a KDTree collection, a cube, a set of individual cubes, and the 
        BVHTree object for each individual cube

        Args:
            resize (Tuple[float]): the size of a cube to create XYZ.
            cuts (int): how many cuts are made to the cube face
                12 cuts == 13 Rows x 13 Columns  
        """

En el siguiente fragmento, seleccionamos la pepita y creamos un cubo delimitador alrededor de esa pepita. Este cubo representa el tamaño de un único pseudo-vóxel de nuestro objeto psuedo-kdtree. Necesitamos usar el bpy.context.view_layer.update() función porque cuando este código se ejecuta desde dentro de una función o secuencia de comandos frente a blender-gui, parece que el view_layer no se actualiza automáticamente.

        # read the nugget,
        # see how large the cube needs to be to encompass a single nugget
        # then touch a parameter to allow it to be smaller or larger (eg more touching)
        bpy.context.view_layer.objects.active = bpy.context.scene.objects.get('nugget_base')
        bpy.ops.object.origin_set(type="ORIGIN_GEOMETRY", center="BOUNDS")
        #create a cube for the bounding box
        bpy.ops.mesh.primitive_cube_add(location=Vector((0,0,0))) 
        #our new cube is now the active object, so we can keep track of it in a variable:
        bound_box = bpy.context.active_object
        bound_box.name="CUBE1"
        bpy.context.view_layer.update()
        #copy transforms
        nug_dims = bpy.data.objects["nugget_base"].dimensions
        bpy.data.objects["CUBE1"].dimensions = nug_dims
        bpy.context.view_layer.update()
        bpy.data.objects["CUBE1"].location = bpy.data.objects["nugget_base"].location
        bpy.context.view_layer.update()
        bpy.data.objects["CUBE1"].rotation_euler = bpy.data.objects["nugget_base"].rotation_euler
        bpy.context.view_layer.update()
        print("bound_box.dimensions: ", bound_box.dimensions)
        print("bound_box.location:", bound_box.location)

A continuación, actualizamos ligeramente nuestro objeto de cubo para que su largo y ancho sean cuadrados, a diferencia del tamaño natural de la pepita a partir de la cual se creó:

        # this cube created isn't always square, but we're going to make it square
        # to fit into our 
        x, y, z = bound_box.dimensions
        v = max(x, y)
        if np.round(v) < v:
            v = np.round(v)+1
        bb_x, bb_y = v, v
        bound_box.dimensions = Vector((v, v, z))
        bpy.context.view_layer.update()
        print("bound_box.dimensions updated: ", bound_box.dimensions)
        # now we generate a plane
        # calc the size of the plane given a max number of boxes.

Ahora usamos nuestro objeto de cubo actualizado para crear un plano que puede contener volumétricamente num_objects cantidad de pepitas:

        x, y, z = bound_box.dimensions
        bb_loc = bound_box.location
        bb_rot_eu = bound_box.rotation_euler
        min_area = (x*y)*num_objects
        min_length = min_area / num_objects
        print(min_length)
        # now we generate a plane
        # calc the size of the plane given a max number of boxes.
        bpy.ops.mesh.primitive_plane_add(location=Vector((0,0,0)), size = min_length)
        plane = bpy.context.selected_objects[0]
        plane.name="PLANE"
        # move our plane to our background collection
        # current_collection = plane.users_collection
        link_object('PLANE', 'BACKGROUND')
        bpy.context.view_layer.update()

Tomamos nuestro objeto plano y creamos un cubo gigante del mismo largo y ancho que nuestro plano, con la altura de nuestro cubo pepita, CUBO1:

        # New Collection
        my_coll = bpy.data.collections.new("KDTREE")
        # Add collection to scene collection
        bpy.context.scene.collection.children.link(my_coll)
        # now we generate cubes based on the size of the plane.
        bpy.ops.mesh.primitive_cube_add(location=Vector((0,0,0)), size = min_length)
        bpy.context.view_layer.update()
        cube = bpy.context.selected_objects[0]
        cube_dimensions = cube.dimensions
        bpy.context.view_layer.update()
        cube.dimensions = Vector((cube_dimensions[0], cube_dimensions[1], z))
        bpy.context.view_layer.update()
        cube.location = bb_loc
        bpy.context.view_layer.update()
        cube.rotation_euler = bb_rot_eu
        bpy.context.view_layer.update()
        cube.name="cube"
        bpy.context.view_layer.update()
        current_collection = cube.users_collection
        link_object('cube', 'KDTREE')
        bpy.context.view_layer.update()

A partir de aquí, queremos crear vóxeles a partir de nuestro cubo. Tomamos el número de cubos que cabría num_objects y luego cortarlos de nuestro objeto cubo. Buscamos la cara de malla que mira hacia arriba de nuestro cubo y luego elegimos esa cara para hacer nuestros cortes. Ver el siguiente código:

        # get the bb volume and make the proper cuts to the object 
        bb_vol = x*y*z
        cube_vol = cube_dimensions[0]*cube_dimensions[1]*cube_dimensions[2]
        n_cubes = cube_vol / bb_vol
        cuts = n_cubes / ((x+y) / 2)
        cuts = int(np.round(cuts)) - 1 # 
        # select the cube
        for object in bpy.data.objects:
            object.select_set(False)
        bpy.context.view_layer.update()
        for object in bpy.data.objects:
            object.select_set(False)
        bpy.data.objects['cube'].select_set(True) # Blender 2.8x
        bpy.context.view_layer.objects.active = bpy.context.scene.objects.get('cube')
        # set to edit mode
        bpy.ops.object.mode_set(mode="EDIT", toggle=False)
        print('edit mode success')
        # get face_data
        context = bpy.context
        obj = context.edit_object
        me = obj.data
        mat = obj.matrix_world
        bm = bmesh.from_edit_mesh(me)
        up_face = None
        # select upwards facing cube-face
        # https://blender.stackexchange.com/questions/43067/get-a-face-selected-pointing-upwards
        for face in bm.faces:
            if (face.normal-UP_VECTOR).length < EPSILON:
                up_face = face
                break
        assert(up_face)
        # subdivide the edges to get the perfect kdtree cubes
        bmesh.ops.subdivide_edges(bm,
                edges=up_face.edges,
                use_grid_fill=True,
                cuts=cuts)
        bpy.context.view_layer.update()
        # get the center point of each face

Por último, calculamos el centro de la cara superior de cada corte que hemos hecho a partir de nuestro cubo grande y creamos cubos reales a partir de esos cortes. Cada uno de estos cubos recién creados representa una sola pieza de espacio para generar o mover pepitas alrededor de nuestro plano. Ver el siguiente código:

        face_data = 
        sizes = []
        for f, face in enumerate(bm.faces): 
            face_data[f] = 
            face_data[f]['calc_center_bounds'] = face.calc_center_bounds()
            loc = mat @ face_data[f]['calc_center_bounds']
            face_data[f]['loc'] = loc
            sizes.append(loc[-1])
        # get the most common cube-z; we use this to determine the correct loc
        counter = Counter()
        counter.update(sizes)
        most_common = counter.most_common()[0][0]
        cube_loc = mat @ cube.location
        # get out of edit mode
        bpy.ops.object.mode_set(mode="OBJECT", toggle=False)
        # go to new colection
        bvhtrees = 
        for f in face_data:
            loc = face_data[f]['loc']
            loc = mat @ face_data[f]['calc_center_bounds']
            print(loc)
            if loc[-1] == most_common:
                # set it back down to the floor because the face is elevated to the
                # top surface of the cube
                loc[-1] = cube_loc[-1]
                bpy.ops.mesh.primitive_cube_add(location=loc, size = x)
                cube = bpy.context.selected_objects[0]
                cube.dimensions = Vector((x, y, z))
                # bpy.context.view_layer.update()
                cube.name = "cube_".format(f)
                #my_coll.objects.link(cube)
                link_object("cube_".format(f), 'KDTREE')
                #bpy.context.view_layer.update()
                bvhtrees[f] = 
        for object in bpy.data.objects:
            object.select_set(False)
        bpy.data.objects['CUBE1'].select_set(True) # Blender 2.8x
        bpy.ops.object.delete()
        return bvhtrees

A continuación, desarrollamos un algoritmo que comprende qué cubos están ocupados en un momento dado, encuentra qué objetos se superponen entre sí y mueve los objetos superpuestos por separado al espacio desocupado. No podremos deshacernos por completo de todas las superposiciones, pero podemos hacer que parezca lo suficientemente real.



Ver el siguiente código:

    def find_occupied_space(
        self, 
        objects3d: List[Object3D],
    ) -> None:
        """
        discover which cube's bvhtree is occupied in our kdtree space

        Args:
            list of Object3D objects

        """
        count = 0
        occupied = []
        for i in self.bvhtrees:
            bvhtree = self.bvhtrees[i]['object']
            for object3d in objects3d:
                if object3d.bvht.overlap(bvhtree.bvht):
                    self.bvhtrees[i]['occupied'] = 1

    def find_overlapping_objects(
        self, 
        objects3d: List[Object3D],
    ) -> List[Tuple[int]]:
        """
        returns which Object3D objects are overlapping

        Args:
            list of Object3D objects
        
        Returns:
            List of indicies from objects3d that are overlap
        """
        count = 0
        overlaps = []
        for i, x_object3d in enumerate(objects3d):
            for ii, y_object3d in enumerate(objects3d[i+1:]):
                if x_object3d.bvht.overlap(y_object3d.bvht):
                    overlaps.append((i, ii))
        return overlaps

    def calc_most_overlapped(
        self,
        overlaps: List[Tuple[int]]
    ) -> List[Tuple[int]]:
        """
        Algorithm to count the number of edges each index has
        and return a sorted list from most->least with the number
        of edges each index has. 

        Args:
            list of indicies that are overlapping
        
        Returns:
            list of indicies with the total number of overlapps they have 
            [index, count]
        """
        keys = 
        for x,y in overlaps:
            if x not in keys:
                keys[x] = 0
            if y not in keys:
                keys[y] = 0
            keys[x]+=1
            keys[y]+=1
        # sort by most edges first
        index_counts = sorted(keys.items(), key=lambda x: x[1])[::-1]
        return index_counts
    
    def get_random_unoccupied(
        self
    ) -> Union[int,None]:
        """
        returns a randomly chosen unoccuped kdtree cube

        Return
            either the kdtree cube's key or None (meaning all spaces are
            currently occupied)
            Union[int,None]
        """
        unoccupied = []
        for i in self.bvhtrees:
            if not self.bvhtrees[i]['occupied']:
                unoccupied.append(i)
        if unoccupied:
            random.shuffle(unoccupied)
            return unoccupied[0]
        else:
            return None

    def regenerate(
        self,
        iterable: Union[None, List[Object3D]] = None
    ) -> None:
        """
        this function recalculates each objects world-view information
        we default to None, which means we're recalculating the self.bvhtree cubes

        Args:
            iterable (None or List of Object3D objects). if None, we default to
            recalculating the kdtree
        """
        if isinstance(iterable, list):
            for object in iterable:
                object.regenerate()
        else:
            for idx in self.bvhtrees:
                self.bvhtrees[idx]['object'].regenerate()
                self.update_tree(idx, occupied=0)       

    def process_trees_and_objects(
        self,
        objects3d: List[Object3D],
    ) -> List[Tuple[int]]:
        """
        This function finds all overlapping objects within objects3d,
        calculates the objects with the most overlaps, searches within
        the kdtree cube space to see which cubes are occupied. It then returns 
        the edge-counts from the most overlapping objects

        Args:
            list of Object3D objects
        Returns
            this returns the output of most_overlapped
        """
        overlaps = self.find_overlapping_objects(objects3d)
        most_overlapped = self.calc_most_overlapped(overlaps)
        self.find_occupied_space(objects3d)
        return most_overlapped

    def move_objects(
        self, 
        objects3d: List[Object3D],
        most_overlapped: List[Tuple[int]],
        z_increase_offset: float = 2.,
    ) -> None:
        """
        This function iterates through most-overlapped, and uses 
        the index to extract the matching object from object3d - it then
        finds a random unoccupied kdtree cube and moves the given overlapping
        object to that space. It does this for each index from the most-overlapped
        function

        Args:
            objects3d: list of Object3D objects
            most_overlapped: a list of tuples (index, count) - where index relates to
                where it's found in objects3d and count - how many times it overlaps 
                with other objects
            z_increase_offset: this value increases the Z value of the object in order to
                make it appear as though it's off the floor. If you don't augment this value
                the object looks like it's 'inside' the ground plane
        """
        for idx, cnt in most_overlapped:
            object3d = objects3d[idx]
            unoccupied_idx = self.get_random_unoccupied()
            if unoccupied_idx:
                object3d.object.location =  self.bvhtrees[unoccupied_idx]['object'].object.location
                # ensure the nuggest is above the groundplane
                object3d.object.location[-1] = z_increase_offset
                self.update_tree(unoccupied_idx, occupied=1)
    
    def dynamic_movement(
        self, 
        objects3d: List[Object3D],
        tries: int = 100,
        z_offset: float = 2.,
    ) -> None:
        """
        This function resets all objects to get their current positioning
        and randomly moves objects around in an attempt to avoid any object
        overlaps (we don't want two objects to be spawned in the same position)

        Args:
            objects3d: list of Object3D objects
            tries: int the number of times we want to move objects to random spaces
                to ensure no overlaps are present.
            z_offset: this value increases the Z value of the object in order to
                make it appear as though it's off the floor. If you don't augment this value
                the object looks like it's 'inside' the ground plane (see `move_objects`)
        """
    
        # reset all objects
        self.regenerate(objects3d)
        # regenerate bvhtrees
        self.regenerate(None)

        most_overlapped = self.process_trees_and_objects(objects3d)
        attempts = 0
        while most_overlapped:
            if attempts>=tries:
                break
            self.move_objects(objects3d, most_overlapped, z_offset)
            attempts+=1
            # recalc objects
            self.regenerate(objects3d)
            # regenerate bvhtrees
            self.regenerate(None)
            # recalculate overlaps
            most_overlapped = self.process_trees_and_objects(objects3d)

    def generate_spawn_point(
        self,
    ) -> Vector:
        """
        this function generates a random spawn point by finding which
        of the kdtree-cubes are unoccupied, and returns one of those

        Returns
            the Vector location of the kdtree-cube that's unoccupied
        """
        idx = self.get_random_unoccupied()
        print(idx)
        self.update_tree(idx, occupied=1)
        return self.bvhtrees[idx]['object'].object.location

    def update_tree(
        self,
        idx: int,
        occupied: int,
    ) -> None:
        """
        this function updates the given state (occupied vs. unoccupied) of the
        kdtree given the idx

        Args:
            idx: int
            occupied: int
        """
        self.bvhtrees[idx]['occupied'] = occupied

Tubería de generación de imágenes: Cool running

En esta sección, desglosamos lo que nuestro run función está haciendo.

Inicializamos nuestro DensityController y crear algo llamado protector usando el ImageSaver de zpy. Esto nos permite guardar sin problemas nuestras imágenes renderizadas en cualquier lugar de nuestra elección. Luego añadimos nuestro nugget categoría (y si tuviéramos más categorías, las agregaríamos aquí). Ver el siguiente código:

@gin.configurable("run")
@zpy.blender.save_and_revert
def run(
    max_num_nuggets: int = 100,
    jitter_mesh: bool = True,
    jitter_nugget_scale: bool = True,
    jitter_material: bool = True,
    jitter_nugget_material: bool = False,
    number_of_random_materials: int = 50,
    nugget_texture_path: str = os.getcwd()+"/nugget_textures",
    annotations_path = os.getcwd()+'/nugget_data',
):
    """
    Main run function.
    """
    density_controller = DensityController()
    # Random seed results in unique behavior
    zpy.blender.set_seed(random.randint(0,1000000000))

    # Create the saver object
    saver = zpy.saver_image.ImageSaver(
        description="Image of the randomized Amazon nuggets",
        output_dir=annotations_path,
    )
    saver.add_category(name="nugget")

A continuación, debemos crear un objeto de origen desde el cual generamos nuggets de copia; en este caso, es el nugget_base que creamos:

    # Make a list of source nugget objects
    source_nugget_objects = []
    for obj in zpy.objects.for_obj_in_collections(
        [
            bpy.data.collections["NUGGET"],
        ]
    ):
        assert(obj!=None)

        # pass on everything not named nugget
        if 'nugget_base' not in obj.name:
            print('passing on '.format(obj.name))
            continue
        zpy.objects.segment(obj, name="nugget", as_category=True) #color=nugget_seg_color
        print("zpy.objects.segment: check ".format(obj.name))
        source_nugget_objects.append(obj.name)

Ahora que tenemos nuestro nugget base, vamos a guardar las poses mundiales (ubicaciones) de todos los demás objetos para que, después de cada ejecución de renderizado, podamos usar estas poses guardadas para reiniciar un renderizado. También movemos nuestra pepita base completamente fuera del camino para que el kdtree no detecte que un espacio está ocupado. Finalmente, inicializamos nuestros objetos kdtree-cube. Ver el siguiente código:

    # move nugget point up 10 z's so it won't collide with base-cube
    bpy.data.objects["nugget_base"].location[-1] = 10

    # Save the position of the camera and light
    # create light and camera
    zpy.objects.save_pose("Camera")
    zpy.objects.save_pose("Sun")
    zpy.objects.save_pose("Plane")
    zpy.objects.save_pose("Main Axis")
    axis = bpy.data.objects['Main Axis']
    print('saving poses')
    # add some parameters to this 

    # get the plane-3d object
    plane3d = Object3D(bpy.data.objects['Plane'])

    # generate kdtree cubes
    density_controller.generate_kdtree_cubes()

El siguiente código recopila nuestros fondos descargados de texture.ninja, donde se usarán para proyectarlos aleatoriamente en nuestro avión:

    # Pre-create a bunch of random textures
    #random_materials = [
    #    zpy.material.random_texture_mat() for _ in range(number_of_random_materials)
    #]
    p = os.path.abspath(os.getcwd()+'/random_textures')
    print(p)
    random_materials = []
    for x in os.listdir(p):
        texture_path = Path(os.path.join(p,x))
        y = zpy.material.make_mat_from_texture(texture_path, name=texture_path.stem)
        random_materials.append(y)
    #print(random_materials[0])

    # Pre-create a bunch of random textures
    random_nugget_materials = [
        random_nugget_texture_mat(Path(nugget_texture_path)) for _ in range(number_of_random_materials)
    ]

Aquí es donde comienza la magia. Primero regeneramos kdtree-cubes para esta ejecución para que podamos comenzar de nuevo:

    # Run the sim.
    for step_idx in zpy.blender.step():
        density_controller.generate_kdtree_cubes()

        objects3d = []
        num_nuggets = random.randint(40, max_num_nuggets)
        log.info(f"Spawning  nuggets.")
        spawned_nugget_objects = []
        for _ in range(num_nuggets):

Usamos nuestro controlador de densidad para generar un punto de generación aleatorio para nuestra pepita, creamos una copia de nugget_basey mueva la copia al punto de generación generado aleatoriamente:

            # Choose location to spawn nuggets
            spawn_point = density_controller.generate_spawn_point()
            # manually spawn above the floor
            # spawn_point[-1] = 1.8 #2.0

            # Pick a random object to spawn
            _name = random.choice(source_nugget_objects)
            log.info(f"Spawning a copy of source nugget  at ")
            obj = zpy.objects.copy(
                bpy.data.objects[_name],
                collection=bpy.data.collections["SPAWNED"],
                is_copy=True,
            )

            obj.location = spawn_point
            obj.matrix_world = mathutils.Matrix.Translation(spawn_point)
            spawned_nugget_objects.append(obj)

A continuación, alteramos aleatoriamente el tamaño de la pepita, la malla de la pepita y la escala de la pepita para que no haya dos pepitas iguales:

            # Segment the newly spawned nugget as an instance
            zpy.objects.segment(obj)

            # Jitter final pose of the nugget a little
            zpy.objects.jitter(
                obj,
                rotate_range=(
                    (0.0, 0.0),
                    (0.0, 0.0),
                    (-math.pi * 2, math.pi * 2),
                ),
            )

            if jitter_nugget_scale:
                # Jitter the scale of each nugget
                zpy.objects.jitter(
                    obj,
                    scale_range=(
                        (0.8, 2.0), #1.2
                        (0.8, 2.0), #1.2
                        (0.8, 2.0), #1.2
                    ),
                )

            if jitter_mesh:
                # Jitter (deform) the mesh of each nugget
                zpy.objects.jitter_mesh(
                    obj=obj,
                    scale=(
                        random.uniform(0.01, 0.03),
                        random.uniform(0.01, 0.03),
                        random.uniform(0.01, 0.03),
                    ),
                )

            if jitter_nugget_material:
                # Jitter the material (apperance) of each nugget
                for i in range(len(obj.material_slots)):
                    obj.material_slots[i].material = random.choice(random_nugget_materials)
                    zpy.material.jitter(obj.material_slots[i].material)          

Convertimos nuestra copia nugget en una Object3D objeto donde usamos la funcionalidad del árbol BVH para ver si nuestro plano se cruza o se superpone con alguna cara o vértices en nuestra copia de nugget. Si encontramos una superposición con el plano, simplemente movemos la pepita hacia arriba en su eje Z. Ver el siguiente código:

            # create 3d obj for movement
            nugget3d = Object3D(obj)

            # make sure the bottom most part of the nugget is NOT
            # inside the plane-object       
            plane_overlap(plane3d, nugget3d)

            objects3d.append(nugget3d)

Ahora que se han creado todos los nuggets, usamos nuestro DensityController para mover las pepitas de modo que tengamos un número mínimo de superposiciones, y las que se superponen no tienen un aspecto horrible:

        # ensure objects aren't on top of each other
        density_controller.dynamic_movement(objects3d)

En el siguiente código: restauramos el Camera y Main Axis posa y selecciona aleatoriamente qué tan lejos está la cámara del Plane objeto:

        # Return camera to original position
        zpy.objects.restore_pose("Camera")
        zpy.objects.restore_pose("Main Axis")
        zpy.objects.restore_pose("Camera")
        zpy.objects.restore_pose("Main Axis")

        # assert these are the correct versions...
        assert(bpy.data.objects["Camera"].location == Vector((0,0,100)))
        assert(bpy.data.objects["Main Axis"].location == Vector((0,0,0)))
        assert(bpy.data.objects["Main Axis"].rotation_euler == Euler((0,0,0)))

        # alter the Z ditance with the camera
        bpy.data.objects["Camera"].location = (0, 0, random.uniform(0.75, 3.5)*100)

Decidimos qué tan aleatoriamente queremos que la cámara viaje a lo largo del Main Axis. Dependiendo de si queremos que sea principalmente por encima de la cabeza o si nos importa mucho el ángulo desde el que ve la tabla, podemos ajustar la top_down_mostly parámetro dependiendo de qué tan bien nuestro modelo de entrenamiento esté captando la señal de «¿Qué es una pepita de todos modos?»

        # alter the main-axis beta/gamma params
        top_down_mostly = False 
        if top_down_mostly:
            zpy.objects.rotate(
                bpy.data.objects["Main Axis"],
                rotation=(
                    random.uniform(0.05, 0.05),
                    random.uniform(0.05, 0.05),
                    random.uniform(0.05, 0.05),
                ),
            )
        else:
            zpy.objects.rotate(
                bpy.data.objects["Main Axis"],
                rotation=(
                    random.uniform(-1., 1.),
                    random.uniform(-1., 1.),
                    random.uniform(-1., 1.),
                ),
            )

        print(bpy.data.objects["Main Axis"].rotation_euler)
        print(bpy.data.objects["Camera"].location)

En el siguiente código, hacemos lo mismo con el Sun objeto, y elija al azar una textura para el Plane objeto:

        # change the background material
        # Randomize texture of shelf, floors and walls
        for obj in bpy.data.collections["BACKGROUND"].all_objects:
            for i in range(len(obj.material_slots)):
                # TODO
                # Pick one of the random materials
                obj.material_slots[i].material = random.choice(random_materials)
                if jitter_material:
                    zpy.material.jitter(obj.material_slots[i].material)
                # Sets the material relative to the object
                obj.material_slots[i].link = "OBJECT"
        # Pick a random hdri (from the local textures folder for background background)
        zpy.hdris.random_hdri()
        # Return light to original position
        zpy.objects.restore_pose("Sun")

        # Jitter the light position
        zpy.objects.jitter(
            "Sun",
            translate_range=(
                (-5, 5),
                (-5, 5),
                (-5, 5),
            ),
        )
        bpy.data.objects["Sun"].data.energy = random.uniform(0.5, 7)

Finalmente, ocultamos todos nuestros objetos que no queremos que se rendericen: el nugget_base y toda nuestra estructura de cubo:

# we hide the cube objects<br />for obj in         # we hide the cube objects
        for obj in bpy.data.objects:
            if 'cube' in obj.name:
                obj.hide_render = True
                try:
                    zpy.objects.toggle_hidden(obj, hidden=True)
                except:
                    # deal with this exception here...
                    pass
        # we hide our base nugget object
        bpy.data.objects["nugget_base"].hide_render = True
        zpy.objects.toggle_hidden(bpy.data.objects["nugget_base"], hidden=True)

Por último, usamos zpy para renderizar nuestra escena, guardar nuestras imágenes y luego guardar nuestras anotaciones. Para esta publicación, hice algunos pequeños cambios en el zpy biblioteca de anotaciones para mi caso de uso específico (anotación por imagen en lugar de un archivo por proyecto), pero no debería tener que hacerlo para el propósito de esta publicación).

        # create the image name
        image_uuid = str(uuid.uuid4())

        # Name for each of the output images
        rgb_image_name = format_image_string(image_uuid, 'rgb')
        iseg_image_name = format_image_string(image_uuid, 'iseg')
        depth_image_name = format_image_string(image_uuid, 'depth')

        zpy.render.render(
            rgb_path=saver.output_dir / rgb_image_name,
            iseg_path=saver.output_dir / iseg_image_name,
            depth_path=saver.output_dir / depth_image_name,
        )

        # Add images to saver
        saver.add_image(
            name=rgb_image_name,
            style="default",
            output_path=saver.output_dir / rgb_image_name,
            frame=step_idx,
        )
    
        saver.add_image(
            name=iseg_image_name,
            style="segmentation",
            output_path=saver.output_dir / iseg_image_name,
            frame=step_idx,
        )
        saver.add_image(
            name=depth_image_name,
            style="depth",
            output_path=saver.output_dir / depth_image_name,
            frame=step_idx,
        )

        # ideally in this thread, we'll open the anno file
        # and write to it directly, saving it after each generation
        for obj in spawned_nugget_objects:
            # Add annotation to segmentation image
            saver.add_annotation(
                image=rgb_image_name,
                category="nugget",
                seg_image=iseg_image_name,
                seg_color=tuple(obj.seg.instance_color),
            )

        # Delete the spawned nuggets
        zpy.objects.empty_collection(bpy.data.collections["SPAWNED"])

        # Write out annotations
        saver.output_annotated_images()
        saver.output_meta_analysis()

        # # ZUMO Annotations
        _output_zumo = _OutputZUMO(saver=saver, annotation_filename = Path(image_uuid + ".zumo.json"))
        _output_zumo.output_annotations()
        # change the name here..
        saver.output_annotated_images()
        saver.output_meta_analysis()

        # remove the memory of the annotation to free RAM
        saver.annotations = []
        saver.images = 
        saver.image_name_to_id = 
        saver.seg_annotations_color_to_id = 

    log.info("Simulation complete.")

if __name__ == "__main__":

    # Set the logger levels
    zpy.logging.set_log_levels("info")

    # Parse the gin-config text block
    # hack to read a specific gin config
    parse_config_from_file('nugget_config.gin')

    # Run the sim
    run()

¡Voila!

Ejecute el script de creación sin cabeza

Ahora que tenemos nuestro archivo Blender guardado, nuestro nugget creado y toda la información de apoyo, comprimamos nuestro directorio de trabajo y scp en nuestra máquina GPU o lo cargó a través de Amazon Simple Storage Service (Amazon S3) u otro servicio:

tar cvf working_blender_dir.tar.gz working_blender_dir
scp -i "your.pem" working_blender_dir.tar.gz ubuntu@EC2-INSTANCE.compute.amazonaws.com:/home/ubuntu/working_blender_dir.tar.gz

Inicie sesión en su instancia EC2 y descomprima su carpeta working_blender:

tar xvf working_blender_dir.tar.gz

Ahora creamos nuestros datos en todo su esplendor:

blender working_blender_dir/nugget.blend --background --python working_blender_dir/create_synthetic_nuggets.py

El script debe ejecutarse para 500 imágenes y los datos se guardan en /path/to/working_blender_dir/nugget_data.

El siguiente código muestra una única anotación creada con nuestro conjunto de datos:

{
    "metadata": ,
    "categories": ,
    "images": ,
    "annotations": [
        ,
        ,
...
...
...

Conclusión

En esta publicación, demostré cómo usar la biblioteca de animación de código abierto Blender para construir una canalización de datos sintéticos de extremo a extremo.

Hay un montón de cosas geniales que puedes hacer en Blender y AWS; ¡Con suerte, esta demostración puede ayudarlo en su próximo proyecto hambriento de datos!

Referencias


Sobre el Autor

mate krzus es científico de datos sénior en Amazon Web Service en el grupo de servicios profesionales de AWS

Fuente del artículo

Deja un comentario