Criação de uma DSL para geração de imagens

Olá, Habr! Faltam alguns dias para o lançamento de um novo curso da OTUS "Backend Development on Kotlin" . Na véspera do início do curso, preparamos para você uma tradução de outro material interessante.












Freqüentemente, ao resolver problemas relacionados à visão computacional, a falta de dados se torna um grande problema. Isso é especialmente verdadeiro ao trabalhar com redes neurais.



Não seria legal se tivéssemos uma fonte ilimitada de novos dados originais?



Esse pensamento me levou a desenvolver uma Linguagem Específica de Domínio, que permite criar imagens em várias configurações. Essas imagens podem ser usadas para treinar e testar modelos de aprendizado de máquina. Como o nome sugere, as imagens geradas por DSL geralmente só podem ser usadas em uma área com foco estreito.



Requisitos de idioma



No meu caso específico, preciso me concentrar na detecção de objetos. O compilador de linguagem deve gerar imagens que atendam aos seguintes critérios:



  • as imagens contêm diferentes formas (por exemplo, emoticons);
  • o número e a posição das figuras individuais são personalizáveis;
  • o tamanho e as formas da imagem são personalizáveis.


A linguagem em si deve ser o mais simples possível. Quero determinar o tamanho da imagem de saída primeiro e depois o tamanho das formas. Em seguida, quero expressar a configuração real da imagem. Para manter as coisas simples, penso na imagem como uma mesa, onde cada forma se encaixa em uma célula. Cada nova linha é preenchida com formulários da esquerda para a direita.



Implementação



Escolhi uma combinação de ANTLR, Kotlin e Gradle para criar o DSL . ANTLR é um gerador de analisador. Kotlin é uma linguagem semelhante a JVM semelhante ao Scala. Gradle é um sistema de compilação semelhante ao sbt.



Ambiente necessário



Você precisará do Java 1.8 e do Gradle 4.6 para concluir as etapas descritas.



Configuração inicial



Crie uma pasta para conter o DSL.



> mkdir shaperdsl
> cd shaperdsl


Crie um arquivo build.gradle. Este arquivo é necessário para listar as dependências do projeto e configurar tarefas adicionais do Gradle. Se você quiser reutilizar este arquivo, você só precisa alterar os namespaces e a classe principal.



> touch build.gradle


Abaixo está o conteúdo do arquivo:



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
}


Analisador de linguagem



O analisador é construído como a gramática ANTLR .



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


com o seguinte conteúdo:



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';


Agora você pode ver como a estrutura da linguagem fica mais clara. Para gerar o código-fonte da gramática, execute:



> gradle generateGrammarSource


Como resultado, você obterá o código gerado no 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


Árvore de sintaxe abstrata



O analisador converte o código-fonte em uma árvore de objetos. A árvore de objetos é o que o compilador usa como fonte de dados. Para obter o AST, primeiro você precisa definir o metamodelo da árvore.



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


MetaModel.ktcontém as definições das classes de objetos utilizadas na linguagem, a partir da raiz. Todos eles herdam do Nodo . A hierarquia da árvore é visível na definição da classe.



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


Em seguida, você precisa combinar a classe com ASD:



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


Mapping.kté usado para construir um AST usando as classes definidas em MetaModel.kt, usando dados do analisador.



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)


O código em nosso DSL:



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


Será convertido para o seguinte ASD:







Compilador



O compilador é a última parte. Ele usa o ASD para obter um resultado específico, neste caso, uma imagem.



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


Há muito código neste arquivo. Vou tentar esclarecer os pontos principais.



ShaperParserFacadeÉ um invólucro superior ShaperAntlrParserFacadeque cria o AST real a partir do código-fonte fornecido.



Shaper2Imageé a principal classe do compilador. Depois de receber o AST do analisador, ele percorre todos os objetos dentro dele e cria objetos gráficos, que então insere na imagem. Em seguida, ele retorna a representação binária da imagem. Também há uma função mainno objeto complementar da classe para permitir o teste.



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)
        }
    }
}


Agora que tudo está pronto, vamos construir o projeto e obter um arquivo jar com todas as dependências ( uber jar ).



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


Testando



Tudo o que precisamos fazer é verificar se tudo funciona, então tente inserir 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


Um arquivo será criado:



.png


que ficará assim:







Conclusão



Esta é uma DSL simples, não é segura e provavelmente quebrará se for usada incorretamente. No entanto, atende bem ao meu propósito e posso usá-lo para criar qualquer número de amostras de imagens exclusivas. Ele pode ser facilmente estendido para maior flexibilidade e pode ser usado como um modelo para outras DSLs.



Um exemplo completo de DSL pode ser encontrado em meu repositório GitHub: github.com/cosmincatalin/shaper .



Consulte Mais informação






All Articles