Jelajahi Sumber

Added weather image generation

Vadik Sirekanyan 4 tahun lalu
induk
melakukan
25191691d7
59 mengubah file dengan 212 tambahan dan 11 penghapusan
  1. 1 0
      .gitignore
  2. TEMPAT SAMPAH
      data/200.png
  3. TEMPAT SAMPAH
      data/201.png
  4. TEMPAT SAMPAH
      data/202.png
  5. TEMPAT SAMPAH
      data/210.png
  6. TEMPAT SAMPAH
      data/211.png
  7. TEMPAT SAMPAH
      data/212.png
  8. TEMPAT SAMPAH
      data/221.png
  9. TEMPAT SAMPAH
      data/230.png
  10. TEMPAT SAMPAH
      data/231.png
  11. TEMPAT SAMPAH
      data/232.png
  12. TEMPAT SAMPAH
      data/300.png
  13. TEMPAT SAMPAH
      data/301.png
  14. TEMPAT SAMPAH
      data/302.png
  15. TEMPAT SAMPAH
      data/310.png
  16. TEMPAT SAMPAH
      data/311.png
  17. TEMPAT SAMPAH
      data/312.png
  18. TEMPAT SAMPAH
      data/321.png
  19. TEMPAT SAMPAH
      data/500.png
  20. TEMPAT SAMPAH
      data/501.png
  21. TEMPAT SAMPAH
      data/502.png
  22. TEMPAT SAMPAH
      data/503.png
  23. TEMPAT SAMPAH
      data/504.png
  24. TEMPAT SAMPAH
      data/511.png
  25. TEMPAT SAMPAH
      data/520.png
  26. TEMPAT SAMPAH
      data/521.png
  27. TEMPAT SAMPAH
      data/522.png
  28. TEMPAT SAMPAH
      data/600.png
  29. TEMPAT SAMPAH
      data/601.png
  30. TEMPAT SAMPAH
      data/602.png
  31. TEMPAT SAMPAH
      data/611.png
  32. TEMPAT SAMPAH
      data/621.png
  33. TEMPAT SAMPAH
      data/701.png
  34. TEMPAT SAMPAH
      data/711.png
  35. TEMPAT SAMPAH
      data/721.png
  36. TEMPAT SAMPAH
      data/731.png
  37. TEMPAT SAMPAH
      data/741.png
  38. TEMPAT SAMPAH
      data/800.png
  39. TEMPAT SAMPAH
      data/801.png
  40. TEMPAT SAMPAH
      data/802.png
  41. TEMPAT SAMPAH
      data/803.png
  42. TEMPAT SAMPAH
      data/804.png
  43. TEMPAT SAMPAH
      data/900.png
  44. TEMPAT SAMPAH
      data/901.png
  45. TEMPAT SAMPAH
      data/902.png
  46. TEMPAT SAMPAH
      data/903.png
  47. TEMPAT SAMPAH
      data/904.png
  48. TEMPAT SAMPAH
      data/905.png
  49. TEMPAT SAMPAH
      data/906.png
  50. TEMPAT SAMPAH
      data/Montserrat-Light.ttf
  51. 14 3
      src/main/kotlin/com/sirekanyan/andersrobot/AndersController.kt
  52. 29 5
      src/main/kotlin/com/sirekanyan/andersrobot/api/Weather.kt
  53. 2 2
      src/main/kotlin/com/sirekanyan/andersrobot/extensions/AbsSender.kt
  54. 17 0
      src/main/kotlin/com/sirekanyan/andersrobot/extensions/Map.kt
  55. 19 0
      src/main/kotlin/com/sirekanyan/andersrobot/image/FileProvider.kt
  56. 1 1
      src/main/kotlin/com/sirekanyan/andersrobot/image/ForecastPlotter.kt
  57. 64 0
      src/main/kotlin/com/sirekanyan/andersrobot/image/ImageGenerator.kt
  58. 21 0
      src/main/kotlin/com/sirekanyan/andersrobot/image/ImageProvider.kt
  59. 44 0
      src/test/kotlin/com/sirekanyan/andersrobot/MapTest.kt

+ 1 - 0
.gitignore

@@ -4,3 +4,4 @@
 /bot.properties
 /bot.properties.debug
 /lets-plot-images
+/weather-*.png

TEMPAT SAMPAH
data/200.png


TEMPAT SAMPAH
data/201.png


TEMPAT SAMPAH
data/202.png


TEMPAT SAMPAH
data/210.png


TEMPAT SAMPAH
data/211.png


TEMPAT SAMPAH
data/212.png


TEMPAT SAMPAH
data/221.png


TEMPAT SAMPAH
data/230.png


TEMPAT SAMPAH
data/231.png


TEMPAT SAMPAH
data/232.png


TEMPAT SAMPAH
data/300.png


TEMPAT SAMPAH
data/301.png


TEMPAT SAMPAH
data/302.png


TEMPAT SAMPAH
data/310.png


TEMPAT SAMPAH
data/311.png


TEMPAT SAMPAH
data/312.png


TEMPAT SAMPAH
data/321.png


TEMPAT SAMPAH
data/500.png


TEMPAT SAMPAH
data/501.png


TEMPAT SAMPAH
data/502.png


TEMPAT SAMPAH
data/503.png


TEMPAT SAMPAH
data/504.png


TEMPAT SAMPAH
data/511.png


TEMPAT SAMPAH
data/520.png


TEMPAT SAMPAH
data/521.png


TEMPAT SAMPAH
data/522.png


TEMPAT SAMPAH
data/600.png


TEMPAT SAMPAH
data/601.png


TEMPAT SAMPAH
data/602.png


TEMPAT SAMPAH
data/611.png


TEMPAT SAMPAH
data/621.png


TEMPAT SAMPAH
data/701.png


TEMPAT SAMPAH
data/711.png


TEMPAT SAMPAH
data/721.png


TEMPAT SAMPAH
data/731.png


TEMPAT SAMPAH
data/741.png


TEMPAT SAMPAH
data/800.png


TEMPAT SAMPAH
data/801.png


TEMPAT SAMPAH
data/802.png


TEMPAT SAMPAH
data/803.png


TEMPAT SAMPAH
data/804.png


TEMPAT SAMPAH
data/900.png


TEMPAT SAMPAH
data/901.png


TEMPAT SAMPAH
data/902.png


TEMPAT SAMPAH
data/903.png


TEMPAT SAMPAH
data/904.png


TEMPAT SAMPAH
data/905.png


TEMPAT SAMPAH
data/906.png


TEMPAT SAMPAH
data/Montserrat-Light.ttf


+ 14 - 3
src/main/kotlin/com/sirekanyan/andersrobot/AndersController.kt

@@ -2,9 +2,12 @@ package com.sirekanyan.andersrobot
 
 import com.sirekanyan.andersrobot.api.Forecast
 import com.sirekanyan.andersrobot.api.WeatherApi
+import com.sirekanyan.andersrobot.extensions.logError
 import com.sirekanyan.andersrobot.extensions.sendPhoto
 import com.sirekanyan.andersrobot.extensions.sendText
 import com.sirekanyan.andersrobot.extensions.sendWeather
+import com.sirekanyan.andersrobot.image.generateImage
+import com.sirekanyan.andersrobot.image.plotForecast
 import com.sirekanyan.andersrobot.repository.CityRepository
 import com.sirekanyan.andersrobot.repository.supportedLanguages
 import jetbrains.letsPlot.export.ggsave
@@ -13,6 +16,7 @@ import org.telegram.telegrambots.meta.api.objects.Update
 import org.telegram.telegrambots.meta.bots.AbsSender
 import java.io.File
 import java.util.*
+import javax.imageio.ImageIO
 
 private const val DEFAULT_CITY_ID = 524901L // Moscow
 
@@ -85,9 +89,16 @@ class AndersController(
     private fun showWeather(chatId: Long, language: String?) {
         val dbCities = repository.getCities(chatId)
         val cities = dbCities.ifEmpty { listOf(DEFAULT_CITY_ID) }
-        val temperatures = api.getWeathers(cities, language)
-        check(temperatures.isNotEmpty())
-        sender.sendText(chatId, temperatures.joinToString("\n") { it.format() })
+        val weathers = api.getWeathers(cities, language)
+        check(weathers.isNotEmpty())
+        try {
+            val file = File("weather-$chatId.png")
+            ImageIO.write(generateImage(weathers), "png", file)
+            sender.sendPhoto(chatId, file)
+        } catch (exception: Exception) {
+            sender.logError("Cannot send image to $chatId", exception)
+            sender.sendText(chatId, weathers.joinToString("\n") { it.format() })
+        }
     }
 
     private fun showForecast(chatId: Long, forecast: Forecast, locale: Locale) {

+ 29 - 5
src/main/kotlin/com/sirekanyan/andersrobot/api/Weather.kt

@@ -1,30 +1,54 @@
 package com.sirekanyan.andersrobot.api
 
+import com.sirekanyan.andersrobot.image.FileProvider
+import com.sirekanyan.andersrobot.image.ImageProvider
 import kotlinx.serialization.Serializable
+import java.awt.Color
+import java.awt.image.BufferedImage
 import java.io.File
 import kotlin.math.roundToInt
 
+const val WIDTH = 504
+const val HEIGHT = 64
+private val fileProvider = FileProvider("webp")
+private val imageProvider = ImageProvider("png", WIDTH, HEIGHT)
+private val lightBackgroundIds = setOf(210, 212, 221, 504, 611, 701, 711, 721, 731, 800, 803, 902, 903)
+
 @Serializable
 data class Weather(
     val main: MainInfo,
     val id: Long,
     val name: String,
     val sys: System,
-    val weather: List<Condition>
+    val weather: List<Condition>,
 ) {
 
+    val temperature: String
+        get() = "${main.temp.roundToInt()}°C"
+
+    private val primaryCondition: Condition?
+        get() = weather.firstOrNull()
+
     @Serializable
     data class System(val country: String = "")
 
     @Serializable
     data class Condition(val id: Int)
 
-    fun findImageFile(): File? {
-        val w = weather.firstOrNull() ?: return null
-        return File("data/${w.id}.webp").takeIf { it.exists() }
+    fun findStickerFile(): File? {
+        val id = primaryCondition?.id ?: return null
+        return fileProvider.findFile(id)
+    }
+
+    fun findBackgroundImage(): BufferedImage? {
+        val id = primaryCondition?.id ?: return null
+        return imageProvider.findImage(id)
     }
 
     fun format(): String =
-        "$name ${main.temp.roundToInt()}°C"
+        "$name $temperature"
+
+    fun getTextColor(): Color =
+        if (primaryCondition?.id in lightBackgroundIds) Color.BLACK else Color.WHITE
 
 }

+ 2 - 2
src/main/kotlin/com/sirekanyan/andersrobot/extensions/AbsSender.kt

@@ -19,10 +19,10 @@ fun AbsSender.sendPhoto(chatId: Long, file: File): Message =
     execute(SendPhoto().setChatId(chatId).setPhoto(file))
 
 fun AbsSender.sendWeather(chatId: Long, weather: Weather) {
-    weather.findImageFile()?.let { icon ->
+    weather.findStickerFile()?.let { icon ->
         sendSticker(chatId, icon)
     }
-    sendText(chatId, weather.format())
+    sendText(chatId, "${weather.name} ${weather.temperature}")
 }
 
 private fun AbsSender.sendSticker(chatId: Long, file: File) {

+ 17 - 0
src/main/kotlin/com/sirekanyan/andersrobot/extensions/Map.kt

@@ -0,0 +1,17 @@
+package com.sirekanyan.andersrobot.extensions
+
+import kotlin.math.absoluteValue
+
+fun <T> Map<Int, T>.getClosest(k: Int): T? {
+    var resultKey: Int? = null
+    var resultDiff = Int.MAX_VALUE
+    for (key in keys.sorted()) {
+        val diff = (key - k).absoluteValue
+        if (diff <= resultDiff) {
+            resultKey = key
+            resultDiff = diff
+        }
+    }
+    println("Using $resultKey instead of $k")
+    return get(resultKey)
+}

+ 19 - 0
src/main/kotlin/com/sirekanyan/andersrobot/image/FileProvider.kt

@@ -0,0 +1,19 @@
+package com.sirekanyan.andersrobot.image
+
+import com.sirekanyan.andersrobot.extensions.getClosest
+import java.io.File
+import java.io.FilenameFilter
+
+class FileProvider(extension: String) {
+
+    private val fileRegex = Regex("\\d{3}.$extension")
+    private val fileFilter = FilenameFilter { _, name -> name.matches(fileRegex) }
+    private val files: Map<Int, File> =
+        checkNotNull(File("data").listFiles(fileFilter))
+            .associateBy({ it.nameWithoutExtension.toInt() }, { it })
+
+    fun getFiles(): Map<Int, File> = files
+
+    fun findFile(key: Int): File? = files[key] ?: files.getClosest(key)
+
+}

+ 1 - 1
src/main/kotlin/com/sirekanyan/andersrobot/LetsPlot.kt → src/main/kotlin/com/sirekanyan/andersrobot/image/ForecastPlotter.kt

@@ -1,4 +1,4 @@
-package com.sirekanyan.andersrobot
+package com.sirekanyan.andersrobot.image
 
 import com.sirekanyan.andersrobot.api.Forecast
 import jetbrains.datalore.base.values.Color

+ 64 - 0
src/main/kotlin/com/sirekanyan/andersrobot/image/ImageGenerator.kt

@@ -0,0 +1,64 @@
+package com.sirekanyan.andersrobot.image
+
+import com.sirekanyan.andersrobot.api.HEIGHT
+import com.sirekanyan.andersrobot.api.WIDTH
+import com.sirekanyan.andersrobot.api.Weather
+import java.awt.Font
+import java.awt.Font.TRUETYPE_FONT
+import java.awt.FontMetrics
+import java.awt.Graphics2D
+import java.awt.RenderingHints.KEY_TEXT_ANTIALIASING
+import java.awt.RenderingHints.VALUE_TEXT_ANTIALIAS_ON
+import java.awt.image.BufferedImage
+import java.awt.image.BufferedImage.TYPE_INT_RGB
+import java.io.File
+
+private const val PADDING = 24
+private val montserratFont = Font.createFont(TRUETYPE_FONT, File("data/Montserrat-Light.ttf"))
+private val cityFont = montserratFont.deriveFont(32f)
+private val tempFont = montserratFont.deriveFont(42f)
+
+fun generateImage(weathers: List<Weather>): BufferedImage {
+    val image = BufferedImage(WIDTH, HEIGHT * weathers.size, TYPE_INT_RGB)
+    val graphics = image.createGraphics()
+    graphics.setRenderingHint(KEY_TEXT_ANTIALIASING, VALUE_TEXT_ANTIALIAS_ON)
+    graphics.drawWeatherImages(weathers)
+    graphics.dispose()
+    return image
+}
+
+private fun Graphics2D.drawWeatherImages(weathers: List<Weather>) {
+    weathers.forEachIndexed { index, weather ->
+        weather.findBackgroundImage()?.let { image ->
+            drawWeatherImage(index, image)
+        }
+        drawWeatherText(index, weather)
+    }
+}
+
+private fun Graphics2D.drawWeatherImage(index: Int, image: BufferedImage) {
+    drawImage(image, 0, HEIGHT * index, null)
+}
+
+private fun Graphics2D.drawWeatherText(index: Int, weather: Weather) {
+    color = weather.getTextColor()
+    font = cityFont
+    drawCityText(index, weather.name)
+    font = tempFont
+    drawTempText(index, weather.temperature)
+}
+
+private fun Graphics2D.drawCityText(index: Int, text: String) {
+    val x = PADDING
+    val y = fontMetrics.textY(index)
+    drawString(text, x, y)
+}
+
+private fun Graphics2D.drawTempText(index: Int, text: String) {
+    val x = WIDTH - PADDING - fontMetrics.stringWidth(text)
+    val y = fontMetrics.textY(index)
+    drawString(text, x, y)
+}
+
+private fun FontMetrics.textY(index: Int): Int =
+    (HEIGHT - height) / 2 + ascent + HEIGHT * index

+ 21 - 0
src/main/kotlin/com/sirekanyan/andersrobot/image/ImageProvider.kt

@@ -0,0 +1,21 @@
+package com.sirekanyan.andersrobot.image
+
+import com.sirekanyan.andersrobot.extensions.getClosest
+import java.awt.image.BufferedImage
+import javax.imageio.ImageIO
+
+class ImageProvider(extension: String, width: Int, height: Int) {
+
+    private val fileProvider = FileProvider(extension)
+    private val images: Map<Int, BufferedImage> =
+        fileProvider.getFiles()
+            .mapValues { (_, file) -> ImageIO.read(file) }
+            .onEach { (id, image) ->
+                checkNotNull(image) { "Cannot read image $id.$extension" }
+                check(image.width == width) { "Wrong image width for $id" }
+                check(image.height == height) { "Wrong image height for $id" }
+            }
+
+    fun findImage(key: Int): BufferedImage? = images[key] ?: images.getClosest(key)
+
+}

+ 44 - 0
src/test/kotlin/com/sirekanyan/andersrobot/MapTest.kt

@@ -0,0 +1,44 @@
+package com.sirekanyan.andersrobot
+
+import com.sirekanyan.andersrobot.extensions.getClosest
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class MapTest {
+
+    @Test
+    fun getClosestTest0() {
+        assertEquals(null, mapOf<Int, String>().getClosest(0))
+    }
+
+    @Test
+    fun getClosestTest1() {
+        assertEquals("zero", mapOf(0 to "zero").getClosest(0))
+    }
+
+    @Test
+    fun getClosestTest2() {
+        assertEquals("zero", mapOf(0 to "zero").getClosest(1))
+    }
+
+    @Test
+    fun getClosestTest3() {
+        assertEquals("two", mapOf(0 to "zero", 2 to "two").getClosest(1))
+    }
+
+    @Test
+    fun getClosestTest4() {
+        assertEquals("two", mapOf(2 to "two", 0 to "zero").getClosest(1))
+    }
+
+    @Test
+    fun getClosestTest5() {
+        assertEquals("zero", mapOf(0 to "zero", 3 to "three").getClosest(1))
+    }
+
+    @Test
+    fun getClosestTest6() {
+        assertEquals("three", mapOf(0 to "zero", 3 to "three").getClosest(2))
+    }
+
+}