Создание DSL для генерации изображений

от автора

Привет, Хабр! Считанные дни остаются до запуска нового курса от OTUS «Backend-разработка на Kotlin». В преддверии старта курса мы подготовили для вас перевод еще одного интересного материала.


Часто при решении задач, связанных с компьютерным зрением, недостаток данных становится большой проблемой. Это особенно актуально при работе с нейронными сетями.

Как было бы здорово, будь у нас безграничный источник новых оригинальных данных?

Эта мысль натолкнула меня на разработку предметно-ориентированного языка (Domain Specific Language), который позволяет создавать изображения в различных конфигурациях. Эти изображения можно использовать для обучения и тестирования моделей машинного обучения. Как следует из названия, генерируемые DSL изображения обычно могут использоваться только в узко направленной области.

Требования к языку

В моем конкретном случае необходимо сосредоточиться на обнаружении объектов. Компилятор языка должен генерировать изображения, соответствующие следующим критериям:

  • изображения содержат различные формы (например, смайлики);
  • количество и положение отдельных фигур настраивается;
  • размер изображения и форм настраивается.

Сам язык должен быть максимально простым. Сначала я хочу определить размер выходного изображения, а затем размер фигур. После этого я хочу выразить фактическую конфигурацию изображения. Чтобы упростить задачу, я рассматриваю изображение как таблицу, где каждая фигура помещается в ячейку. Каждый новый ряд заполняется формами слева направо.

Реализация

Для создания DSL я выбрал комбинацию ANTLR, Kotlin и Gradle. ANTLR является генератором парсера. Kotlin – это JVM язык, похожий на Scala. Gradle — это система сборки, похожая на sbt.

Необходимое окружение

Для выполнения описанных действий вам понадобится Java 1.8 и Gradle 4.6.

Первоначальная настройка

Создайте папку, которая будет содержать DSL.

> mkdir shaperdsl > cd shaperdsl

Создайте файл build.gradle. Этот файл нужен для перечисления зависимостей проекта и настройки дополнительных задач Gradle. Если вы захотите повторно использовать этот файл, вам придется изменить лишь пространства имен и основной класс.

> touch build.gradle

Ниже приведено содержание файла:

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 }

Парсер языка

Парсер построен как грамматика ANTLR.

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

со следующим содержанием:

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

Теперь вы видите, как структура языка становится понятнее. Для генерации исходного кода грамматики выполните:

> gradle generateGrammarSource

В итоге вы получите сгенерированный код в 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

Абстрактное синтаксическое дерево

Парсер преобразует исходный код в дерево объектов. Дерево объектов — это то, что компилятор использует в качестве источника данных. Чтобы получить АСД, сначала необходимо определить метамодель дерева.

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

MetaModel.kt содержит определения классов объектов, используемых в языке, начиная с корня. Все они наследуются от интерфейса Node. Древовидная иерархия видна в определении классов.

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

Далее необходимо сопоставить класс с АСД:

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

Mapping.kt используется для построения АСД с использованием классов, определенных в MetaModel.kt, используя данные от парсера.

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)

Код на нашем DSL:

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

Будет преобразован к следующему АСД:

Компилятор

Компилятор — это последняя часть. Он использует АСД для получения конкретного результата, в данном случае, изображения.

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

В этом файле много кода. Я постараюсь пояснить основные моменты.

ShaperParserFacade — это оболочка поверх ShaperAntlrParserFacade, которая создает фактическое АСД из предоставленного исходного кода.

Shaper2Image является основным классом компилятора. После того, как он получает АСД от парсера, он проходит по всем объектам внутри него и создает графические объекты, которые затем вставляет в изображение. Затем он возвращает двоичное представление изображения. Также предусмотрена функция main в объекте-компаньоне класса, позволяющая проводить тестирование.

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

Теперь, когда все готово, соберем проект и получим jar-файл со всеми зависимостями (uber jar).

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

Тестирование

Все, что нам осталось сделать, это проверить, все ли работает, поэтому попробуйте ввести такой код:

> 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

Создастся файл:

.png

который будет выглядеть следующим образом:

Заключение

Это простой DSL, он не защищен, и, вероятно, сломается, если его использовать не по назначению. Тем не менее, он хорошо подходит для моей цели, и я могу использовать его для создания любого количества уникальных сэмплов изображений. Его можно легко расширить для обеспечения большей гибкости и использовать в качестве шаблона для других DSL.

Полный пример DSL можно найти в моем репозитории на GitHub: github.com/cosmincatalin/shaper.

Читать ещё

ссылка на оригинал статьи https://habr.com/ru/company/otus/blog/512058/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *