Привет, Хабр! Считанные дни остаются до запуска нового курса от OTUS «Backend-разработка на Kotlin». В преддверии старта курса мы подготовили для вас перевод еще одного интересного материала.
Часто при решении задач, связанных с компьютерным зрением, недостаток данных становится большой проблемой. Это особенно актуально при работе с нейронными сетями.
Как было бы здорово, будь у нас безграничный источник новых оригинальных данных?
Эта мысль натолкнула меня на разработку предметно-ориентированного языка (Domain Specific Language), который позволяет создавать изображения в различных конфигурациях. Эти изображения можно использовать для обучения и тестирования моделей машинного обучения. Как следует из названия, генерируемые DSL изображения обычно могут использоваться только в узко направленной области.
В моем конкретном случае необходимо сосредоточиться на обнаружении объектов. Компилятор языка должен генерировать изображения, соответствующие следующим критериям:
Сам язык должен быть максимально простым. Сначала я хочу определить размер выходного изображения, а затем размер фигур. После этого я хочу выразить фактическую конфигурацию изображения. Чтобы упростить задачу, я рассматриваю изображение как таблицу, где каждая фигура помещается в ячейку. Каждый новый ряд заполняется формами слева направо.
Для создания DSL я выбрал комбинацию ANTLR, Kotlin и Gradle. ANTLR является генератором парсера. Kotlin – это JVM язык, похожий на Scala. Gradle — это система сборки, похожая на
Для выполнения описанных действий вам понадобится Java 1.8 и Gradle 4.6.
Создайте папку, которая будет содержать DSL.
Создайте файл
Ниже приведено содержание файла:
Парсер построен как грамматика ANTLR.
со следующим содержанием:
Теперь вы видите, как структура языка становится понятнее. Для генерации исходного кода грамматики выполните:
В итоге вы получите сгенерированный код в
Парсер преобразует исходный код в дерево объектов. Дерево объектов — это то, что компилятор использует в качестве источника данных. Чтобы получить АСД, сначала необходимо определить метамодель дерева.
Далее необходимо сопоставить класс с АСД:
Код на нашем DSL:
Будет преобразован к следующему АСД:
Компилятор — это последняя часть. Он использует АСД для получения конкретного результата, в данном случае, изображения.
В этом файле много кода. Я постараюсь пояснить основные моменты.
Теперь, когда все готово, соберем проект и получим jar-файл со всеми зависимостями (uber jar).
Все, что нам осталось сделать, это проверить, все ли работает, поэтому попробуйте ввести такой код:
Создастся файл:
который будет выглядеть следующим образом:
Это простой DSL, он не защищен, и, вероятно, сломается, если его использовать не по назначению. Тем не менее, он хорошо подходит для моей цели, и я могу использовать его для создания любого количества уникальных сэмплов изображений. Его можно легко расширить для обеспечения большей гибкости и использовать в качестве шаблона для других DSL.
Полный пример DSL можно найти в моем репозитории на GitHub: github.com/cosmincatalin/shaper.
Часто при решении задач, связанных с компьютерным зрением, недостаток данных становится большой проблемой. Это особенно актуально при работе с нейронными сетями.
Как было бы здорово, будь у нас безграничный источник новых оригинальных данных?
Эта мысль натолкнула меня на разработку предметно-ориентированного языка (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.
sashagil
Не смотрели ли вы на то, насколько сложно дополнить парсер (в выбранном вами стеке) реализацией базового IDE-плагина (опять же, соответствующего вашему стеку)? Например, если вы используете IDE, поддерживающую Language Server Protocol (LSP), реализация LSP в каком-то объёме может улучшить usability вашего DSL.