Skip to content

Commit ddaff10

Browse files
authored
Merge pull request #199 from Poeschl/import-export
✨ Map download
2 parents 8952af4 + 338eb1d commit ddaff10

File tree

17 files changed

+639
-257
lines changed

17 files changed

+639
-257
lines changed

backend/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ dependencies {
3535
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.6'
3636
implementation 'org.postgresql:postgresql'
3737
implementation 'net.karneim:pojobuilder:4.3.0:annotations'
38+
implementation 'org.apache.commons:commons-imaging:1.0.0-alpha5'
39+
implementation 'org.apache.xmlgraphics:xmlgraphics-commons:2.9'
3840

3941
kapt 'net.karneim:pojobuilder:4.3.0'
4042

backend/src/main/kotlin/xyz/poeschl/roborush/controller/ConfigRestController.kt

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ package xyz.poeschl.roborush.controller
33
import io.swagger.v3.oas.annotations.security.SecurityRequirement
44
import org.apache.commons.lang3.EnumUtils
55
import org.slf4j.LoggerFactory
6+
import org.springframework.core.io.ByteArrayResource
7+
import org.springframework.core.io.Resource
68
import org.springframework.http.MediaType
9+
import org.springframework.http.ResponseEntity
710
import org.springframework.security.access.prepost.PreAuthorize
811
import org.springframework.web.bind.annotation.*
912
import org.springframework.web.multipart.MultipartFile
@@ -18,11 +21,16 @@ import xyz.poeschl.roborush.models.settings.SettingKey
1821
import xyz.poeschl.roborush.repositories.Tile
1922
import xyz.poeschl.roborush.security.repository.User
2023
import xyz.poeschl.roborush.service.ConfigService
24+
import xyz.poeschl.roborush.service.MapImportExportService
2125
import xyz.poeschl.roborush.service.MapService
2226

2327
@RestController
2428
@RequestMapping("/config")
25-
class ConfigRestController(private val configService: ConfigService, private val mapService: MapService) {
29+
class ConfigRestController(
30+
private val configService: ConfigService,
31+
private val mapService: MapService,
32+
private val mapImportExportService: MapImportExportService
33+
) {
2634

2735
companion object {
2836
private val LOGGER = LoggerFactory.getLogger(ConfigRestController::class.java)
@@ -59,9 +67,11 @@ class ConfigRestController(private val configService: ConfigService, private val
5967
throw InvalidHeightMapException("Only png files are supported for heightmaps")
6068
}
6169
val name = heightMapFile.originalFilename?.substringBeforeLast("/")?.substringBeforeLast(".") ?: "unknown"
62-
val mapGenResult = mapService.createNewMapFromHeightMap(name, heightMapFile.inputStream)
70+
val mapGenResult = mapImportExportService.importMap(name, heightMapFile)
6371
mapService.saveMap(mapGenResult.map)
6472

73+
LOGGER.info("Imported map $name")
74+
6575
return MapGenerationResult(mapGenResult.errors)
6676
}
6777

@@ -130,6 +140,24 @@ class ConfigRestController(private val configService: ConfigService, private val
130140
}
131141
}
132142

143+
@SecurityRequirement(name = "Bearer Authentication")
144+
@PreAuthorize("hasRole('${User.ROLE_ADMIN}')")
145+
@GetMapping("/map/{id}/export", produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE])
146+
fun exportMap(@PathVariable id: Long): ResponseEntity<Resource> {
147+
val map = mapService.getMap(id)
148+
149+
if (map != null) {
150+
val resource = ByteArrayResource(mapImportExportService.exportMap(map))
151+
152+
return ResponseEntity.ok()
153+
.contentLength(resource.contentLength())
154+
.contentType(MediaType.APPLICATION_OCTET_STREAM)
155+
.body(resource)
156+
} else {
157+
throw MapNotFound("No matching map found for deletion")
158+
}
159+
}
160+
133161
@SecurityRequirement(name = "Bearer Authentication")
134162
@PreAuthorize("hasRole('${User.ROLE_ADMIN}')")
135163
@PostMapping("/client/globalNotificationText", consumes = [MediaType.APPLICATION_JSON_VALUE])

backend/src/main/kotlin/xyz/poeschl/roborush/models/Color.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ data class Color(val r: Int, val g: Int, val b: Int) {
1717
// Allow a little pixel color delta
1818
@JsonIgnore
1919
fun isGrey() = (r == g || r == g - 1 || r == g + 1) && (g == b || g == b - 1 || g == b + 1) && (b == r || b == r - 1 || b == r + 1)
20+
21+
@JsonIgnore
22+
fun toAwtColor(): java.awt.Color = java.awt.Color(this.r, this.g, this.b)
2023
}
2124

2225
@Converter(autoApply = true)

backend/src/main/kotlin/xyz/poeschl/roborush/models/Map.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,5 @@ class SizeConverter : AttributeConverter<Size, String> {
8080
}
8181

8282
data class InternalMapGenResult(val map: Map, val errors: List<String>)
83+
84+
data class MapMetadata(val solarChargeRate: Double?, val maxRobotFuel: Int?)
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
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

Comments
 (0)