Creando un DSL para generar imágenes

¡Hola, Habr! Quedan unos días para el lanzamiento del nuevo curso de OTUS "Backend Development on Kotlin" . La víspera del inicio del curso, hemos preparado para ti una traducción de otro material interesante.












A menudo, al resolver problemas relacionados con la visión por computadora, la falta de datos se convierte en un gran problema. Esto es especialmente cierto cuando se trabaja con redes neuronales.



¿Qué tan genial sería si tuviéramos una fuente ilimitada de nuevos datos originales?



Este pensamiento me impulsó a desarrollar un lenguaje específico de dominio, que le permite crear imágenes en varias configuraciones. Estas imágenes se pueden usar para entrenar y probar modelos de aprendizaje automático. Como sugiere el nombre, las imágenes DSL generadas generalmente solo se pueden usar en un área de enfoque limitado.



Requisitos de idioma



En mi caso particular, necesito centrarme en la detección de objetos. El compilador del lenguaje debe generar imágenes que cumplan con los siguientes criterios:



  • las imágenes contienen diferentes formas (por ejemplo, emoticonos);
  • el número y la posición de las figuras individuales es personalizable;
  • el tamaño y las formas de la imagen son personalizables.


El lenguaje en sí debería ser lo más simple posible. Primero quiero determinar el tamaño de la imagen de salida y luego el tamaño de las formas. Entonces quiero expresar la configuración real de la imagen. Para simplificar las cosas, pienso en la imagen como una tabla, donde cada forma encaja en una celda. Cada nueva fila está llena de formularios de izquierda a derecha.



Implementación



Elegí una combinación de ANTLR, Kotlin y Gradle para crear la DSL . ANTLR es un generador de analizador sintáctico. Kotlin es un lenguaje similar a JVM similar a Scala. Gradle es un sistema de compilación similar a sbt.



Entorno necesario



Necesitará Java 1.8 y Gradle 4.6 para completar los pasos descritos.



Configuración inicial



Cree una carpeta para contener el DSL.



> mkdir shaperdsl
> cd shaperdsl


Crea un archivo build.gradle. Este archivo es necesario para enumerar las dependencias del proyecto y configurar tareas adicionales de Gradle. Si desea reutilizar este archivo, solo necesita cambiar los espacios de nombres y la clase principal.



> touch build.gradle


A continuación se muestra el contenido del archivo:



buildscript {
   ext.kotlin_version = '1.2.21'
   ext.antlr_version = '4.7.1'
   ext.slf4j_version = '1.7.25'

   repositories {
     mavenCentral()
     maven {
        name 'JFrog OSS snapshot repo'
        url  'https://oss.jfrog.org/oss-snapshot-local/'
     }
     jcenter()
   }

   dependencies {
     classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
     classpath 'com.github.jengelman.gradle.plugins:shadow:2.0.1'
   }
}

apply plugin: 'kotlin'
apply plugin: 'java'
apply plugin: 'antlr'
apply plugin: 'com.github.johnrengelman.shadow'

repositories {
  mavenLocal()
  mavenCentral()
  jcenter()
}

dependencies {
  antlr "org.antlr:antlr4:$antlr_version"
  compile "org.antlr:antlr4-runtime:$antlr_version"
  compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
  compile "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
  compile "org.apache.commons:commons-io:1.3.2"
  compile "org.slf4j:slf4j-api:$slf4j_version"
  compile "org.slf4j:slf4j-simple:$slf4j_version"
  compile "com.audienceproject:simple-arguments_2.12:1.0.1"
}

generateGrammarSource {
    maxHeapSize = "64m"
    arguments += ['-package', 'com.example.shaperdsl']
    outputDirectory = new File("build/generated-src/antlr/main/com/example/shaperdsl".toString())
}
compileJava.dependsOn generateGrammarSource

jar {
    manifest {
        attributes "Main-Class": "com.example.shaperdsl.compiler.Shaper2Image"
    }

    from {
        configurations.compile.collect { it.isDirectory() ? it : zipTree(it) }
    }
}

task customFatJar(type: Jar) {
    manifest {
        attributes 'Main-Class': 'com.example.shaperdsl.compiler.Shaper2Image'
    }
    baseName = 'shaperdsl'
    from { configurations.compile.collect { it.isDirectory() ? it : zipTree(it) } }
    with jar
}


Analizador de idiomas



El analizador está construido como gramática ANTLR .



mkdir -p src/main/antlr
touch src/main/antlr/ShaperDSL.g4


con el siguiente contenido:



grammar ShaperDSL;

shaper      : 'img_dim:' img_dim ',shp_dim:' shp_dim '>>>' ( row ROW_SEP)* row '<<<' NEWLINE* EOF;
row       : ( shape COL_SEP )* shape ;
shape     : 'square' | 'circle' | 'triangle';
img_dim   : NUM ;
shp_dim   : NUM ;

NUM       : [1-9]+ [0-9]* ;
ROW_SEP   : '|' ;
COL_SEP   : ',' ;

NEWLINE   : '\r\n' | 'r' | '\n';


Ahora puedes ver cómo la estructura del lenguaje se vuelve más clara. Para generar el código fuente de la gramática, ejecute:



> gradle generateGrammarSource


Como resultado, obtendrá el código generado en formato build/generate-src/antlr.



> ls build/generated-src/antlr/main/com/example/shaperdsl/
ShaperDSL.interp  ShaperDSL.tokens  ShaperDSLBaseListener.java  ShaperDSLLexer.interp  ShaperDSLLexer.java  ShaperDSLLexer.tokens  ShaperDSLListener.java  ShaperDSLParser.java


Árbol de sintaxis abstracta



El analizador convierte el código fuente en un árbol de objetos. El árbol de objetos es lo que el compilador usa como fuente de datos. Para obtener el AST, primero debe definir el metamodelo del árbol.



> mkdir -p src/main/kotlin/com/example/shaperdsl/ast
> touch src/main/kotlin/com/example/shaper/ast/MetaModel.kt


MetaModel.ktcontiene las definiciones de las clases de objetos utilizadas en el lenguaje, comenzando por la raíz. Todos heredan del Node . La jerarquía del árbol es visible en la definición de clase.



package com.example.shaperdsl.ast

interface Node

data class Shaper(val img_dim: Int, val shp_dim: Int, val rows: List<Row>): Node

data class Row(val shapes: List<Shape>): Node

data class Shape(val type: String): Node


A continuación, debe hacer coincidir la clase con ASD:



> touch src/main/kotlin/com/example/shaper/ast/Mapping.kt


Mapping.ktse usa para construir un AST usando las clases definidas en MetaModel.kt, usando datos del analizador.



package com.example.shaperdsl.ast

import com.example.shaperdsl.ShaperDSLParser

fun ShaperDSLParser.ShaperContext.toAst(): Shaper = Shaper(this.img_dim().text.toInt(), this.shp_dim().text.toInt(), this.row().map { it.toAst() })

fun ShaperDSLParser.RowContext.toAst(): Row = Row(this.shape().map { it.toAst() })

fun ShaperDSLParser.ShapeContext.toAst(): Shape = Shape(text)


El código en nuestro DSL:



img_dim:100,shp_dim:8>>>square,square|circle|triangle,circle,square<<<


Se convertirá al siguiente ASD:







Compilador



El compilador es la última parte. Utiliza ASD para obtener un resultado específico, en este caso, una imagen.



> mkdir -p src/main/kotlin/com/example/shaperdsl/compiler
> touch src/main/kotlin/com/example/shaper/compiler/Shaper2Image.kt


Hay mucho código en este archivo. Intentaré aclarar los puntos principales.



ShaperParserFacadeEs un contenedor en la parte superior ShaperAntlrParserFacadeque crea el AST real a partir del código fuente proporcionado.



Shaper2Imagees la clase de compilador principal. Después de recibir el AST del analizador, pasa por todos los objetos que contiene y crea objetos gráficos, que luego inserta en la imagen. Luego devuelve la representación binaria de la imagen. También hay una función mainen el objeto complementario de la clase para permitir las pruebas.



package com.example.shaperdsl.compiler

import com.audienceproject.util.cli.Arguments
import com.example.shaperdsl.ShaperDSLLexer
import com.example.shaperdsl.ShaperDSLParser
import com.example.shaperdsl.ast.Shaper
import com.example.shaperdsl.ast.toAst
import org.antlr.v4.runtime.CharStreams
import org.antlr.v4.runtime.CommonTokenStream
import org.antlr.v4.runtime.TokenStream
import java.awt.Color
import java.awt.image.BufferedImage
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.InputStream
import javax.imageio.ImageIO

object ShaperParserFacade {

    fun parse(inputStream: InputStream) : Shaper {
        val lexer = ShaperDSLLexer(CharStreams.fromStream(inputStream))
        val parser = ShaperDSLParser(CommonTokenStream(lexer) as TokenStream)
        val antlrParsingResult = parser.shaper()
        return antlrParsingResult.toAst()
    }

}


class Shaper2Image {

    fun compile(input: InputStream): ByteArray {
        val root = ShaperParserFacade.parse(input)
        val img_dim = root.img_dim
        val shp_dim = root.shp_dim

        val bufferedImage = BufferedImage(img_dim, img_dim, BufferedImage.TYPE_INT_RGB)
        val g2d = bufferedImage.createGraphics()
        g2d.color = Color.white
        g2d.fillRect(0, 0, img_dim, img_dim)

        g2d.color = Color.black
        var j = 0
        root.rows.forEach{
            var i = 0
            it.shapes.forEach {
                when(it.type) {
                    "square" -> {
                        g2d.fillRect(i * (shp_dim + 1), j * (shp_dim + 1), shp_dim, shp_dim)
                    }
                    "circle" -> {
                        g2d.fillOval(i * (shp_dim + 1), j * (shp_dim + 1), shp_dim, shp_dim)
                    }
                    "triangle" -> {
                        val x = intArrayOf(i * (shp_dim + 1), i * (shp_dim + 1) + shp_dim / 2, i * (shp_dim + 1) + shp_dim)
                        val y = intArrayOf(j * (shp_dim + 1) + shp_dim, j * (shp_dim + 1), j * (shp_dim + 1) + shp_dim)
                        g2d.fillPolygon(x, y, 3)
                    }
                }
                i++
            }
            j++
        }

        g2d.dispose()
        val baos = ByteArrayOutputStream()
        ImageIO.write(bufferedImage, "png", baos)
        baos.flush()
        val imageInByte = baos.toByteArray()
        baos.close()
        return imageInByte

    }

    companion object {

        @JvmStatic
        fun main(args: Array<String>) {
            val arguments = Arguments(args)
            val code = ByteArrayInputStream(arguments.arguments()["source-code"].get().get().toByteArray())
            val res = Shaper2Image().compile(code)
            val img = ImageIO.read(ByteArrayInputStream(res))
            val outputfile = File(arguments.arguments()["out-filename"].get().get())
            ImageIO.write(img, "png", outputfile)
        }
    }
}


Ahora que todo está listo, construyamos el proyecto y obtengamos un archivo jar con todas las dependencias ( uber jar ).



> gradle shadowJar
> ls build/libs
shaper-dsl-all.jar


Pruebas



Todo lo que tenemos que hacer es verificar si todo funciona, así que intente ingresar este código:



> java -cp build/libs/shaper-dsl-all.jar com.example.shaperdsl.compiler.Shaper2Image \
--source-code "img_dim:100,shp_dim:8>>>circle,square,square,triangle,triangle|triangle,circle|square,circle,triangle,square|circle,circle,circle|triangle<<<" \
--out-filename test.png


Se creará un archivo:



.png


que se verá así:







Conclusión



Este es un DSL simple, no es seguro y probablemente se romperá si se usa incorrectamente. Sin embargo, se adapta bien a mi propósito y puedo usarlo para crear cualquier cantidad de muestras de imágenes únicas. Se puede ampliar fácilmente para obtener más flexibilidad y se puede utilizar como plantilla para otras DSL.



Se puede encontrar un ejemplo completo de DSL en mi repositorio de GitHub: github.com/cosmincatalin/shaper .



Lee mas






All Articles