Erstellen eines DSL zum Generieren von Bildern

Hallo Habr! Bis zum Start eines neuen Kurses von OTUS "Backend-Entwicklung auf Kotlin" verbleiben noch einige Tage . Am Vorabend des Kursbeginns haben wir für Sie eine Übersetzung eines weiteren interessanten Materials vorbereitet.












Bei der Lösung von Problemen im Zusammenhang mit der Bildverarbeitung wird der Mangel an Daten häufig zu einem großen Problem. Dies gilt insbesondere für die Arbeit mit neuronalen Netzen.



Wie cool wäre es, wenn wir eine unbegrenzte Quelle für neue Originaldaten hätten?



Dieser Gedanke veranlasste mich, eine domänenspezifische Sprache zu entwickeln, mit der Sie Bilder in verschiedenen Konfigurationen erstellen können. Diese Bilder können zum Trainieren und Testen von Modellen für maschinelles Lernen verwendet werden. Wie der Name schon sagt, können DSL-generierte Bilder normalerweise nur in einem eng fokussierten Bereich verwendet werden.



Sprachanforderungen



In meinem speziellen Fall muss ich mich auf die Objekterkennung konzentrieren. Der Sprachcompiler muss Bilder generieren, die die folgenden Kriterien erfüllen:



  • Bilder enthalten verschiedene Formen (z. B. Emoticons);
  • Die Anzahl und Position der einzelnen Figuren ist anpassbar.
  • Bildgröße und -formen sind anpassbar.


Die Sprache selbst sollte so einfach wie möglich sein. Ich möchte zuerst die Größe des Ausgabebildes und dann die Größe der Formen bestimmen. Dann möchte ich die tatsächliche Konfiguration des Bildes ausdrücken. Um die Dinge einfach zu halten, stelle ich mir das Bild als Tabelle vor, in der jede Form in eine Zelle passt. Jede neue Zeile wird von links nach rechts mit Formularen gefüllt.



Implementierung



Ich habe eine Kombination aus ANTLR, Kotlin und Gradle gewählt , um das DSL zu erstellen . ANTLR ist ein Parser-Generator. Kotlin ist eine JVM-ähnliche Sprache ähnlich wie Scala. Gradle ist ein ähnliches Build-System wie sbt.



Notwendiges Umfeld



Sie benötigen Java 1.8 und Gradle 4.6, um die beschriebenen Schritte auszuführen.



Ersteinrichtung



Erstellen Sie einen Ordner mit DSL.



> mkdir shaperdsl
> cd shaperdsl


Erstellen Sie eine Datei build.gradle. Diese Datei wird benötigt, um Projektabhängigkeiten aufzulisten und zusätzliche Gradle-Aufgaben zu konfigurieren. Wenn Sie diese Datei wiederverwenden möchten, müssen Sie nur die Namespaces und die Hauptklasse ändern.



> touch build.gradle


Unten ist der Inhalt der Datei:



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
}


Sprachparser



Der Parser ist wie eine ANTLR- Grammatik aufgebaut .



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


mit folgendem Inhalt:



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


Jetzt können Sie sehen, wie die Struktur der Sprache klarer wird. Führen Sie Folgendes aus, um den Grammatik-Quellcode zu generieren:



> gradle generateGrammarSource


Als Ergebnis erhalten Sie den generierten Code in 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


Abstrakter Syntaxbaum



Der Parser konvertiert den Quellcode in einen Objektbaum. Der Objektbaum wird vom Compiler als Datenquelle verwendet. Um den AST zu erhalten, müssen Sie zuerst das Baummetamodell definieren.



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


MetaModel.ktenthält die Definitionen der in der Sprache verwendeten Objektklassen, beginnend mit der Wurzel. Sie alle erben vom Knoten . Die Baumhierarchie ist in der Klassendefinition sichtbar.



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


Als nächstes müssen Sie die Klasse mit ASD abgleichen:



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


Mapping.ktwird verwendet, um einen AST unter Verwendung der in definierten Klassen MetaModel.ktunter Verwendung von Daten aus dem Parser zu erstellen.



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)


Der Code auf unserer DSL:



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


Wird in die folgende ASD konvertiert:







Compiler



Der Compiler ist der letzte Teil. Er verwendet ASD, um ein bestimmtes Ergebnis zu erhalten, in diesem Fall ein Bild.



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


Diese Datei enthält viel Code. Ich werde versuchen, die wichtigsten Punkte zu klären.



ShaperParserFacadeIst ein Wrapper oben ShaperAntlrParserFacade, der den tatsächlichen AST aus dem bereitgestellten Quellcode erstellt.



Shaper2Imageist die Haupt-Compiler-Klasse. Nachdem es den AST vom Parser empfangen hat, durchläuft es alle darin enthaltenen Objekte und erstellt grafische Objekte, die es dann in das Bild einfügt. Dann wird die binäre Darstellung des Bildes zurückgegeben. Es gibt auch eine Funktion mainim Begleitobjekt der Klasse, um das Testen zu ermöglichen.



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


Nachdem alles fertig ist, erstellen wir das Projekt und erhalten eine JAR-Datei mit allen Abhängigkeiten ( über JAR ).



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


Testen



Wir müssen nur überprüfen, ob alles funktioniert. Geben Sie also diesen Code ein:



> 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


Eine Datei wird erstellt:



.png


was so aussehen wird:







Fazit



Es ist ein einfaches DSL, ungesichert und wird wahrscheinlich kaputt gehen, wenn es missbraucht wird. Es passt jedoch gut zu meinem Zweck und ich kann damit beliebig viele einzigartige Bildbeispiele erstellen. Es kann für mehr Flexibilität einfach erweitert und als Vorlage für andere DSLs verwendet werden.



Ein vollständiges DSL-Beispiel finden Sie in meinem GitHub-Repository: github.com/cosmincatalin/shaper .



Weiterlesen






All Articles