|
| 1 | +package xyz.poeschl.roborush.service |
| 2 | + |
| 3 | +import org.apache.commons.imaging.Imaging |
| 4 | +import org.apache.commons.imaging.formats.png.PngImagingParameters |
| 5 | +import org.apache.commons.imaging.formats.png.PngWriter |
| 6 | +import org.apache.xmlgraphics.util.QName |
| 7 | +import org.apache.xmlgraphics.xmp.Metadata |
| 8 | +import org.apache.xmlgraphics.xmp.XMPParser |
| 9 | +import org.apache.xmlgraphics.xmp.XMPProperty |
| 10 | +import org.apache.xmlgraphics.xmp.XMPSerializer |
| 11 | +import org.slf4j.LoggerFactory |
| 12 | +import org.springframework.core.io.InputStreamSource |
| 13 | +import org.springframework.stereotype.Service |
| 14 | +import org.xml.sax.SAXException |
| 15 | +import xyz.poeschl.roborush.exceptions.NoStartingPosition |
| 16 | +import xyz.poeschl.roborush.exceptions.NoTargetPosition |
| 17 | +import xyz.poeschl.roborush.exceptions.UnknownTileType |
| 18 | +import xyz.poeschl.roborush.models.* |
| 19 | +import xyz.poeschl.roborush.repositories.Map |
| 20 | +import xyz.poeschl.roborush.repositories.Tile |
| 21 | +import java.awt.Graphics |
| 22 | +import java.awt.image.BufferedImage |
| 23 | +import java.io.ByteArrayOutputStream |
| 24 | +import java.io.InputStream |
| 25 | +import java.nio.charset.StandardCharsets |
| 26 | +import java.util.* |
| 27 | +import javax.imageio.ImageIO |
| 28 | +import javax.xml.transform.TransformerConfigurationException |
| 29 | +import javax.xml.transform.stream.StreamSource |
| 30 | +import kotlin.jvm.optionals.getOrNull |
| 31 | +import kotlin.time.measureTime |
| 32 | + |
| 33 | +@Service |
| 34 | +class MapImportExportService { |
| 35 | + |
| 36 | + companion object { |
| 37 | + private val LOGGER = LoggerFactory.getLogger(MapImportExportService::class.java) |
| 38 | + |
| 39 | + private const val XMP_URI = "https://github.com/Poeschl/RoboRush" |
| 40 | + private const val XMP_PREFIX = "rr:" |
| 41 | + private const val XMP_MAP_SOLAR_CHARGE_RATE_KEY = "${XMP_PREFIX}solarChargeRate" |
| 42 | + private const val XMP_MAP_MAX_ROBOT_FUEL_KEY = "${XMP_PREFIX}maxRobotFuel" |
| 43 | + } |
| 44 | + |
| 45 | + fun exportMap(map: Map): ByteArray { |
| 46 | + val image = BufferedImage(map.size.width, map.size.height, BufferedImage.TYPE_INT_RGB) |
| 47 | + val graphics = image.createGraphics() |
| 48 | + |
| 49 | + map.mapData.forEach { drawTile(graphics, it) } |
| 50 | + graphics.dispose() |
| 51 | + |
| 52 | + val imageBytesWithMetadata = setMapMetadata(image, MapMetadata(map.solarChargeRate, map.maxRobotFuel)) |
| 53 | + return imageBytesWithMetadata |
| 54 | + } |
| 55 | + |
| 56 | + private fun drawTile(graphics: Graphics, tile: Tile) { |
| 57 | + graphics.color = getTileColor(tile).toAwtColor() |
| 58 | + graphics.drawRect(tile.position.x, tile.position.y, 1, 1) |
| 59 | + } |
| 60 | + |
| 61 | + private fun getTileColor(tile: Tile): Color = when (tile.type) { |
| 62 | + TileType.TARGET_TILE -> Color(255, tile.height, tile.height) |
| 63 | + TileType.START_TILE -> Color(tile.height, 255, tile.height) |
| 64 | + TileType.FUEL_TILE -> Color(tile.height, tile.height, 255) |
| 65 | + TileType.DEFAULT_TILE -> Color(tile.height, tile.height, tile.height) |
| 66 | + } |
| 67 | + |
| 68 | + /** |
| 69 | + * Generates a new map object from the given height map image and returns all detected recoverable errors as result. |
| 70 | + * |
| 71 | + * @return A list of detected errors as string list. All of those errors are somehow escaped, but the map might not be ideal. |
| 72 | + */ |
| 73 | + fun importMap(mapName: String, heightMapFile: InputStreamSource): InternalMapGenResult { |
| 74 | + LOGGER.info("Create new map from height map image") |
| 75 | + |
| 76 | + val result: InternalMapGenResult |
| 77 | + val generationDuration = measureTime { |
| 78 | + result = convertHeightImageToMap(mapName, heightMapFile) |
| 79 | + } |
| 80 | + |
| 81 | + LOGGER.info("Created map '{}' ({}x{}) in {} ms", result.map.mapName, result.map.size.width, result.map.size.height, generationDuration.inWholeMilliseconds) |
| 82 | + return result |
| 83 | + } |
| 84 | + |
| 85 | + private fun convertHeightImageToMap(mapName: String, heightMapFile: InputStreamSource): InternalMapGenResult { |
| 86 | + val image = ImageIO.read(heightMapFile.inputStream.buffered()) |
| 87 | + |
| 88 | + val startingPositions = mutableListOf<Position>() |
| 89 | + var targetPosition: Position? = null |
| 90 | + val tiles = mutableListOf<Tile>() |
| 91 | + val errors = mutableListOf<String>() |
| 92 | + |
| 93 | + for (y in 0..<image.height) { |
| 94 | + for (x in 0..<image.width) { |
| 95 | + val pos = Position(x, y) |
| 96 | + val pixelColor = Color.fromColorInt(image.getRGB(x, y)) |
| 97 | + |
| 98 | + var tileData: TileData |
| 99 | + try { |
| 100 | + tileData = getTileData(pixelColor) |
| 101 | + } catch (ex: UnknownTileType) { |
| 102 | + LOGGER.warn("Unknown tile type detected at ({},{}) with color {}. Inserting default!", pos.x, pos.y, pixelColor.toString()) |
| 103 | + errors.add("Unknown tile type detected at (%d,%d) with color (%d, %d, %d).".format(pos.x, pos.y, pixelColor.r, pixelColor.g, pixelColor.b)) |
| 104 | + tileData = TileData(0, TileType.DEFAULT_TILE) |
| 105 | + } |
| 106 | + |
| 107 | + when (tileData.type) { |
| 108 | + TileType.DEFAULT_TILE -> tiles.add(Tile(null, pos, tileData.height, tileData.type)) |
| 109 | + TileType.FUEL_TILE -> tiles.add(Tile(null, pos, tileData.height, tileData.type)) |
| 110 | + |
| 111 | + TileType.START_TILE -> { |
| 112 | + tiles.add(Tile(null, pos, tileData.height, tileData.type)) |
| 113 | + startingPositions.add(pos) |
| 114 | + LOGGER.debug("Detected start point at ({},{})", pos.x, pos.y) |
| 115 | + } |
| 116 | + |
| 117 | + TileType.TARGET_TILE -> { |
| 118 | + if (targetPosition == null) { |
| 119 | + tiles.add(Tile(null, pos, tileData.height, tileData.type)) |
| 120 | + targetPosition = pos |
| 121 | + LOGGER.debug("Detected target point at ({},{})", pos.x, pos.y) |
| 122 | + } else { |
| 123 | + errors.add("Multiple target positions detected. Using first one (%d,%d) and skip all others!".format(pos.x, pos.y)) |
| 124 | + tiles.add(Tile(null, pos, tileData.height, TileType.DEFAULT_TILE)) |
| 125 | + } |
| 126 | + } |
| 127 | + } |
| 128 | + } |
| 129 | + } |
| 130 | + |
| 131 | + if (startingPositions.isEmpty()) { |
| 132 | + LOGGER.warn("No starting position detected") |
| 133 | + throw NoStartingPosition("At least one starting position is required") |
| 134 | + } |
| 135 | + |
| 136 | + if (targetPosition == null) { |
| 137 | + LOGGER.warn("No target position detected") |
| 138 | + throw NoTargetPosition("At least one target position is required") |
| 139 | + } |
| 140 | + |
| 141 | + val map = Map(null, mapName, Size(image.width, image.height), startingPositions, targetPosition) |
| 142 | + // Add all tiles to map for the db relations |
| 143 | + tiles.forEach { map.addTile(it) } |
| 144 | + |
| 145 | + val mapMetadata = getMapMetadata(heightMapFile.inputStream.buffered()) |
| 146 | + mapMetadata?.let { |
| 147 | + // use existing metadata, if no metadata exists use defaults |
| 148 | + // same for single missing values |
| 149 | + mapMetadata.solarChargeRate?.let { |
| 150 | + map.solarChargeRate = mapMetadata.solarChargeRate |
| 151 | + } |
| 152 | + mapMetadata.maxRobotFuel?.let { |
| 153 | + map.maxRobotFuel = mapMetadata.maxRobotFuel |
| 154 | + } |
| 155 | + } |
| 156 | + |
| 157 | + return InternalMapGenResult(map, errors) |
| 158 | + } |
| 159 | + |
| 160 | + private data class TileData(val height: Int, val type: TileType) |
| 161 | + |
| 162 | + private fun getTileData(color: Color): TileData { |
| 163 | + return when { |
| 164 | + color.isGrey() -> { |
| 165 | + val height = color.r |
| 166 | + TileData(height, TileType.DEFAULT_TILE) |
| 167 | + } |
| 168 | + |
| 169 | + color.g > color.r && color.r == color.b -> { |
| 170 | + // starting points |
| 171 | + val height = color.r |
| 172 | + TileData(height, TileType.START_TILE) |
| 173 | + } |
| 174 | + |
| 175 | + color.r > color.g && color.g == color.b -> { |
| 176 | + // target points |
| 177 | + val height = color.g |
| 178 | + TileData(height, TileType.TARGET_TILE) |
| 179 | + } |
| 180 | + |
| 181 | + color.b > color.r && color.g == color.r -> { |
| 182 | + val height = color.r |
| 183 | + TileData(height, TileType.FUEL_TILE) |
| 184 | + } |
| 185 | + |
| 186 | + else -> { |
| 187 | + throw UnknownTileType("Unknown tile type detected") |
| 188 | + } |
| 189 | + } |
| 190 | + } |
| 191 | + |
| 192 | + private fun setMapMetadata(imageInput: BufferedImage, mapMetadata: MapMetadata): ByteArray { |
| 193 | + val xmpMetadata = Metadata() |
| 194 | + |
| 195 | + // Set metadata form stored map attributes |
| 196 | + val solarChargeProp = XMPProperty(QName(XMP_URI, XMP_MAP_SOLAR_CHARGE_RATE_KEY), mapMetadata.solarChargeRate!!) |
| 197 | + xmpMetadata.setProperty(solarChargeProp) |
| 198 | + val maxRobotFuelProp = XMPProperty(QName(XMP_URI, XMP_MAP_MAX_ROBOT_FUEL_KEY), mapMetadata.maxRobotFuel!!) |
| 199 | + xmpMetadata.setProperty(maxRobotFuelProp) |
| 200 | + |
| 201 | + try { |
| 202 | + val xmpString = ByteArrayOutputStream().use { |
| 203 | + XMPSerializer.writeXMPPacket(xmpMetadata, it, false) |
| 204 | + return@use it.toString(StandardCharsets.UTF_8) |
| 205 | + } |
| 206 | + |
| 207 | + return ByteArrayOutputStream().use { |
| 208 | + val pngParams = PngImagingParameters() |
| 209 | + pngParams.isForceTrueColor = true |
| 210 | + pngParams.xmpXml = xmpString |
| 211 | + PngWriter().writeImage(imageInput, it, pngParams, null) |
| 212 | + return@use it.toByteArray() |
| 213 | + } |
| 214 | + } catch (ex: TransformerConfigurationException) { |
| 215 | + LOGGER.warn("Couldn't write metadata!", ex) |
| 216 | + } catch (ex: SAXException) { |
| 217 | + LOGGER.warn("Couldn't write metadata!", ex) |
| 218 | + } |
| 219 | + |
| 220 | + // In case of error write image without metadata |
| 221 | + return ByteArrayOutputStream().use { |
| 222 | + val pngParams = PngImagingParameters() |
| 223 | + pngParams.isForceTrueColor = true |
| 224 | + PngWriter().writeImage(imageInput, it, pngParams, null) |
| 225 | + return@use it.toByteArray() |
| 226 | + } |
| 227 | + } |
| 228 | + |
| 229 | + private fun getMapMetadata(imageInput: InputStream): MapMetadata? { |
| 230 | + val xmpString = Imaging.getXmpXml(imageInput.readAllBytes()) |
| 231 | + if (xmpString != null && xmpString.isNotEmpty() && xmpString.contains(XMP_URI)) { |
| 232 | + val xmpMetadata = XMPParser.parseXMP(StreamSource(xmpString.byteInputStream().buffered())) |
| 233 | + |
| 234 | + val solarChargeProp = Optional.ofNullable(xmpMetadata.getProperty(XMP_URI, XMP_MAP_SOLAR_CHARGE_RATE_KEY)).getOrNull() |
| 235 | + val maxRobotFuelProp = Optional.ofNullable(xmpMetadata.getProperty(XMP_URI, XMP_MAP_MAX_ROBOT_FUEL_KEY)).getOrNull() |
| 236 | + |
| 237 | + return MapMetadata( |
| 238 | + (solarChargeProp?.value as String).toDoubleOrNull(), |
| 239 | + (maxRobotFuelProp?.value as String).toIntOrNull() |
| 240 | + ) |
| 241 | + } else { |
| 242 | + return null |
| 243 | + } |
| 244 | + } |
| 245 | +} |
0 commit comments