Skip to content

Commit

Permalink
Add scaled pixel colour distance threshold
Browse files Browse the repository at this point in the history
  • Loading branch information
Dantevg committed Nov 30, 2024
1 parent e4cbcf7 commit ce32fb7
Show file tree
Hide file tree
Showing 6 changed files with 100 additions and 62 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ data class Config(
"(use localhost for Dynmap running on the same server)"
)
val dynmapHost: String = "localhost:8123",

@SerialName("auto-combine")
@YamlComment(
"Whether to automatically combine the tiles into a single image.",
"Disabling this can reduce server lag if you encounter it (for large images),",
"but you'll need to combine the tiles yourself.",
)
val autoCombine: Boolean = true,

@YamlComment(
"A list of export configurations. Example:",
" exports:",
Expand All @@ -47,20 +47,23 @@ data class ExportConfig(
val world: String,
val map: String,
val zoom: Int = 0,
@SerialName("change-threshold")
val changeThreshold: Double = 0.2,
@SerialName("area-change-threshold")
val areaChangeThreshold: Double = 0.1,
@SerialName("colour-change-threshold")
val colourChangeThreshold: Double = 0.1,
val from: WorldCoords,
val to: WorldCoords,
) {
constructor(
world: DynmapWebAPI.World,
map: DynmapWebAPI.Map,
zoom: Int,
changeThreshold: Double,
areaChangeThreshold: Double,
colourChangeThreshold: Double,
from: WorldCoords,
to: WorldCoords = from,
) : this(world.name, map.name, zoom, changeThreshold, from, to)

) : this(world.name, map.name, zoom, areaChangeThreshold, colourChangeThreshold, from, to)
/**
* Get all tile locations from this export config.
*
Expand All @@ -69,16 +72,16 @@ data class ExportConfig(
fun toTileLocations(map: DynmapWebAPI.Map): List<TileCoords> {
val tiles: MutableList<TileCoords> = ArrayList()
val (fromTile, toTile) = toMinMaxTileCoords(map)

for (x in fromTile.x..toTile.x step (1 shl zoom)) {
for (y in fromTile.y..toTile.y step (1 shl zoom)) {
tiles += TileCoords(x, y)
}
}

return tiles
}

fun toMinMaxTileCoords(map: DynmapWebAPI.Map): Pair<TileCoords, TileCoords> {
val fromTile = from.toTileCoords(map, zoom)
val toTile = to.toTileCoords(map, zoom)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class Downloader(private val dynmapExport: DynmapExport) {
downloadedFiles[tile] = dest
download(tilePath, dest)
}

if (downloadedFiles.isNotEmpty() && cached != null
&& !dynmapExport.imageThresholdCache.anyChangedSince(cached, config, downloadedFiles.values)
) {
Expand All @@ -37,13 +37,13 @@ class Downloader(private val dynmapExport: DynmapExport) {
}
return downloadedFiles.size
}

private fun removeExportDir(config: ExportConfig, instant: Instant) {
val dir = Paths.getLocalExportDir(dynmapExport, config, instant)
dir.listFiles()?.forEach(File::delete)
dir.delete()
}

/**
* Remove all but the last export directory, except ones that do not have
* an associated auto-combined image.
Expand All @@ -57,13 +57,13 @@ class Downloader(private val dynmapExport: DynmapExport) {
?.filter(File::isDirectory)
?.filter { dir.list()?.contains(it.name + ".png") ?: false }
.orEmpty()

for (exportDir in exportDirs) {
val instant = Paths.getInstantFromFile(exportDir)
if (instant != lastExport) removeExportDir(config, instant)
}
}

/**
* Remove all but the last exported image and export directory.
* @param config the export configuration
Expand All @@ -77,7 +77,7 @@ class Downloader(private val dynmapExport: DynmapExport) {
if (export.isDirectory) removeExportDir(config, instant) else export.delete()
}
}

/**
* Remove all exported files, including directories.
*/
Expand All @@ -86,10 +86,10 @@ class Downloader(private val dynmapExport: DynmapExport) {
if (file.isDirectory) for (subfile in file.listFiles().orEmpty()) delete(subfile)
file.delete()
}

delete(dynmapExport.exportsDir)
}

/**
* Download a single tile at the given location.
*
Expand All @@ -106,10 +106,10 @@ class Downloader(private val dynmapExport: DynmapExport) {
val map = world.getMapByName(mapName)
?: throw IllegalArgumentException("not a valid map")
val worldCoords = WorldCoords(x, Y_LEVEL, z)
val config = ExportConfig(world, map, zoom, 0.0, worldCoords)
val config = ExportConfig(world, map, zoom, 0.0, 0.0, worldCoords)
return downloadTile(config, worldCoords.toTileCoords(map, zoom))
}

/**
* Download a single tile at the given location.
*
Expand All @@ -123,7 +123,7 @@ class Downloader(private val dynmapExport: DynmapExport) {
val dest: File = Paths.getLocalTileFile(dynmapExport, config, Instant.now(), tileCoords)
return if (download(tilePath, dest)) dest.path else null
}

/**
* Download the Dynmap tile at `path` to `dest`.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,46 +18,58 @@ typealias CommandFeedback = (String) -> Unit
interface DynmapExport {
val logger: Logger
val exportsDir: File

var config: Config
var worldConfiguration: DynmapWebAPI.Configuration?
var imageThresholdCache: ImageThresholdCache
var downloader: Downloader

fun reload()
fun debug() = "Dynmap world configuration:\n$worldConfiguration"

fun purge(all: Boolean) {
if (all) downloader.removeAllExports()
else {
for (exportConfig in config.exports) downloader.removeOldExports(exportConfig)
}
}

fun export(commandFeedback: CommandFeedback): Int {
var nExported = 0
val now = Instant.now()
for (exportConfig in config.exports) {
commandFeedback("Exporting map ${exportConfig.world}:${exportConfig.map}")
val map = worldConfiguration?.getMapByName(exportConfig.world, exportConfig.map)
if (map == null) {
commandFeedback("${exportConfig.world}:${exportConfig.map} is not a valid map")
continue
}
val downloadedTiles = downloader.downloadTiles(exportConfig, now, map)
if (downloadedTiles > 0) {
nExported++
if (config.autoCombine && TileCombiner(this, exportConfig, map, now).combineAndSave()) {
downloader.removeOldExportDirs(exportConfig)
}
}
nExported += export(commandFeedback, exportConfig, now)
}

logger.info("Exported $nExported configs, skipped ${config.exports.size - nExported}")
commandFeedback("Exported $nExported configs, skipped ${config.exports.size - nExported}")
return nExported
}


fun export(commandFeedback: CommandFeedback, exportConfig: ExportConfig, now: Instant): Int {
val map = worldConfiguration?.getMapByName(exportConfig.world, exportConfig.map)
if (map == null) {
commandFeedback("${exportConfig.world}:${exportConfig.map} is not a valid world:map")
return 0
}
commandFeedback("Exporting map ${exportConfig.world}:${exportConfig.map}")

val downloadedTiles = downloader.downloadTiles(exportConfig, now, map)
if (downloadedTiles < 0) {
commandFeedback("No changes detected, skipping export")
return 0
} else if (downloadedTiles == 0) {
commandFeedback("No tiles downloaded, configuration may be incorrect")
return 0
}

if (config.autoCombine && TileCombiner(this, exportConfig, map, now).combineAndSave()) {
downloader.removeOldExportDirs(exportConfig)
}

return 1
}

@OptIn(ExperimentalSerializationApi::class)
fun DynmapExport.getDynmapConfiguration(): DynmapWebAPI.Configuration? {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,10 @@ import java.awt.image.BufferedImage
import java.io.File
import java.io.IOException
import java.time.Instant
import java.util.logging.Level
import javax.imageio.ImageIO
import kotlin.math.max

class ImageThresholdCache(private val dynmapExport: DynmapExport) {
fun anyChangedSince(since: Instant, config: ExportConfig, files: Collection<File>): Boolean =
files.any { hasChangedSince(since, config, it) }

/**
* Get the instant of the last export.
*
Expand All @@ -22,6 +19,9 @@ class ImageThresholdCache(private val dynmapExport: DynmapExport) {
return mapDir.listFiles()?.maxOfOrNull(Paths::getInstantFromFile)
}

fun anyChangedSince(since: Instant, config: ExportConfig, files: Collection<File>): Boolean =
files.any { hasChangedSince(since, config, it) }

private fun hasChangedSince(since: Instant, config: ExportConfig, file: File): Boolean {
val image = try {
ImageIO.read(file)
Expand All @@ -39,23 +39,45 @@ class ImageThresholdCache(private val dynmapExport: DynmapExport) {
dynmapExport.logger.warn("Could not read image from $file")
return true
}
return getFractionPixelsChanged(from, image) >= config.changeThreshold
}

private fun getFractionPixelsChanged(from: BufferedImage, to: BufferedImage): Double {
val pixelsChanged = getNPixelsChanged(from, to)
val totalPixels = to.width * to.height
return pixelsChanged.toDouble() / totalPixels
return fractionPixelsChanged(from, image, config.colourChangeThreshold) >= config.areaChangeThreshold
}
}

private fun fractionPixelsChanged(from: BufferedImage, to: BufferedImage, colourChangeThreshold: Double): Double {
val pixelsChanged = nPixelsChanged(from, to, colourChangeThreshold)
val totalPixels = to.width * to.height
return pixelsChanged.toDouble() / totalPixels
}

private fun nPixelsChanged(from: BufferedImage, to: BufferedImage, colourChangeThreshold: Double): Int {
assert(from.width == to.width)
assert(from.height == to.height)

private fun getNPixelsChanged(from: BufferedImage, to: BufferedImage): Int {
assert(from.width == to.width)
assert(from.height == to.height)

return (0 until from.width).sumOf { x ->
(0 until from.height).count { y ->
from.getRGB(x, y) != to.getRGB(x, y)
}
return (0 until from.width).sumOf { x ->
(0 until from.height).count { y ->
colourDifference(from.getRGB(x, y), to.getRGB(x, y)) >= colourChangeThreshold
}
}
}

/**
* Calculate the colour difference between two RGB colours.
* The result is a number between 0 and 1, where 0 means the colours are the same,
* and 1 means the colours are completely different.
*
* @param from the first colour
* @param to the second colour
* @return the colour difference on the scale `[0,1]`
*/
private fun colourDifference(from: RGB, to: RGB): Double =
(1 - minOf(
from.red() / to.red(), to.red() / from.red(),
from.green() / to.green(), to.green() / from.green(),
from.blue() / to.blue(), to.blue() / from.blue(),
)).toDouble() / 0xFF

typealias RGB = Int

private fun RGB.red(): Int = max(1, (this ushr 16) and 0xFF)
private fun RGB.green(): Int = max(1, (this ushr 8) and 0xFF)
private fun RGB.blue(): Int = max(1, this and 0xFF)
5 changes: 3 additions & 2 deletions dynmapexport-spigot/src/main/kotlin/DynmapExportPlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ object DynmapExportPlugin : JavaPlugin(), DynmapExport {
val worldName = exportMap["world"] as String?
val mapName = exportMap["map"] as String?
val zoom = exportMap["zoom"] as Int
val changeThreshold = exportMap["change-threshold"] as Double
val areaChangeThreshold = exportMap["area-change-threshold"] as Double
val colourChangeThreshold = exportMap["pixel-change-threshold"] as Double
val fromMap =
ensurePresent(exportMap["from"] as Map<String, Int>, "export is missing field 'from'")
?: return null
Expand Down Expand Up @@ -82,6 +83,6 @@ object DynmapExportPlugin : JavaPlugin(), DynmapExport {
"$mapName is not a valid map for world $worldName"
) ?: return null

return ExportConfig(world, map, zoom, changeThreshold, from, to)
return ExportConfig(world, map, zoom, areaChangeThreshold, colourChangeThreshold, from, to)
}
}
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ org.gradle.parallel=true
minecraft_version=1.21

# Project properties
version=1.4.1-SNAPSHOT
version=1.5.0-SNAPSHOT
group=nl.dantevg

# Dependencies
Expand Down

0 comments on commit ce32fb7

Please sign in to comment.