Привет, Хабр! Считанные дни остаются до запуска нового курса
от 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 generateGrammarSourcejar { 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/antlrtouch 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.astinterface Nodedata class Shaper(val img_dim: Int, val shp_dim: Int, val rows: List<Row>): Nodedata class Row(val shapes: List<Shape>): Nodedata class Shape(val type: String): Node
Далее необходимо сопоставить класс с АСД:
> touch src/main/kotlin/com/example/shaper/ast/Mapping.kt
Mapping.kt
используется для построения АСД с
использованием классов, определенных в
MetaModel.kt
,
используя данные от парсера.
package com.example.shaperdsl.astimport com.example.shaperdsl.ShaperDSLParserfun 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.compilerimport com.audienceproject.util.cli.Argumentsimport com.example.shaperdsl.ShaperDSLLexerimport com.example.shaperdsl.ShaperDSLParserimport com.example.shaperdsl.ast.Shaperimport com.example.shaperdsl.ast.toAstimport org.antlr.v4.runtime.CharStreamsimport org.antlr.v4.runtime.CommonTokenStreamimport org.antlr.v4.runtime.TokenStreamimport java.awt.Colorimport java.awt.image.BufferedImageimport java.io.ByteArrayInputStreamimport java.io.ByteArrayOutputStreamimport java.io.Fileimport java.io.InputStreamimport javax.imageio.ImageIOobject 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/libsshaper-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.
Читать ещё