RectSLSoftRasterFigure.kt
src/jvmMain/kotlin/afield/lang/rectsl/RectSLSoftRasterFigure.kt · 197 lines
package afield.lang.rectsl
import afield.media.image.util.PngEncoder
import kotlin.math.abs
A soft raster, its exact reference, and the difference between them — as a picture.
The third panel is |soft - exact| on a heat ramp, with its own peak printed in the corner. It
shows where a soft raster's bias lives: a thin fringe along an oblique edge, a bright bar down a
union seam, a whole feature glowing when it is thinner than the band.
The figure is a CPU raster, for the reason RectSLFitFigure gives: per-pixel coverage is the data here, so a GPU would add only a device, a window and a render target. The labels use the same 3×5 dot-matrix font RectSL's own plotting words use.
object RectSLSoftRasterFigure
Renders soft-versus-exact coverage figures as PNG panels.
object RectSLSoftRasterFigure {
The palette, dark like the rest of Field's captures.
private const val BACKGROUND = 0xFF0F1115.toInt()
private const val RULE = 0xFF2A2F38.toInt()
private const val LABEL = 0xFFDCE1E8.toInt()
data class Panel
One panel of the figure: a coverage image, a label, and how to colour it.
data class Panel(
/** Printed top-left, in the dot-matrix font — digits, `-`, `.` and lower-case letters only. */
val label: String,
/** Row-major values in `0..1`. */
val values: DoubleArray,
/**
* True to draw on a black-red-yellow-white heat ramp instead of grey.
*
* Reserved for difference panels. A difference drawn in grey next to two grey images reads as
* a third picture of the scene, which is exactly the wrong thing for a reader to conclude.
*/
val heat: Boolean = false,
/**
* Multiplied into the value before colouring, so a small difference is visible.
*
* Printed into the label by [renderPanels] rather than applied silently: a difference panel
* scaled by 8 with no note is a lie about the size of the bias.
*/
val gain: Double = 1.0
) {
override fun equals(other: Any?): Boolean
override fun equals(other: Any?): Boolean =
other is Panel && label == other.label && heat == other.heat && gain == other.gain &&
values.contentEquals(other.values)
override fun hashCode(): Int
override fun hashCode(): Int =
31 * (31 * (31 * label.hashCode() + values.contentHashCode()) + heat.hashCode()) + gain.hashCode()
}
fun renderBias(field: SceneAdField, spec: SoftRasterSpec, time: Double, scale: Int = 2): ByteArray
The three-panel figure for one scene and one raster spec: soft, exact, and their difference.
The difference panel's gain is chosen from the data — 1/peak, so the worst pixel is white —
and the peak is printed in the label, which is what stops the auto-gain from flattering.
fun renderBias(field: SceneAdField, spec: SoftRasterSpec, time: Double, scale: Int = 2): ByteArray {
val soft = softRaster(field, spec, time)
val exact = exactRaster(field, spec, time)
val diff = DoubleArray(soft.size) { abs(soft[it] - exact[it]) }
val peak = diff.maxOrNull() ?: 0.0
return renderPanels(
listOf(
Panel("soft ${"%.2f".format(spec.betaInPixels)}px", soft),
Panel("exact area", exact),
Panel("diff peak ${"%.4f".format(peak)}", diff, heat = true, gain = if (peak > 0.0) 1.0 / peak else 1.0)
),
spec.width, spec.height, scale
)
}
fun render(field: SceneAdField, spec: SoftRasterSpec, time: Double, scale: Int = 2): ByteArray
The soft raster alone, at scale screen pixels per raster pixel.
fun render(field: SceneAdField, spec: SoftRasterSpec, time: Double, scale: Int = 2): ByteArray =
renderPanels(
listOf(Panel("soft ${"%.2f".format(spec.betaInPixels)}px", softRaster(field, spec, time))),
spec.width, spec.height, scale
)
fun renderPanels(panels: List<Panel>, width: Int, height: Int, scale: Int = 2): ByteArray
Lays panels out in a row, each width x height raster pixels at scale screen pixels per
pixel, with a one-pixel rule between them and the label drawn over the top-left corner.
@throws IllegalArgumentException when a panel is not width * height long — a silently
truncated panel would render as a picture with a black band, which reads as data.
body — 45 lines, from line 90
fun renderPanels(panels: List<Panel>, width: Int, height: Int, scale: Int = 2): ByteArray {
require(panels.isNotEmpty()) { "a figure needs at least one panel" }
require(scale >= 1) { "scale must be at least 1, got $scale" }
panels.forEach {
require(it.values.size == width * height) {
"panel '${it.label}' has ${it.values.size} values, not ${width * height}"
}
// A label with an unmapped character renders as a gap, which looks like a design choice
// rather than a bug. Refuse it here instead, where the caller can see why.
val unmapped = it.label.uppercase().filter { ch -> PLOT_GLYPHS[ch] == null }.toSet()
require(unmapped.isEmpty()) {
"panel label '${it.label}' uses ${unmapped.joinToString()} , which PLOT_GLYPHS has no " +
"glyph for — it would render as blank space"
}
}
val pad = 8
val panelW = width * scale
val panelH = height * scale
val labelHeight = 5 * 2 + 6
val outW = pad + panels.size * (panelW + pad)
val outH = pad + labelHeight + panelH + pad
val pixels = IntArray(outW * outH) { BACKGROUND }
panels.forEachIndexed { index, panel ->
val left = pad + index * (panelW + pad)
val top = pad + labelHeight
for (j in 0 until panelH) {
for (i in 0 until panelW) {
val v = panel.values[(j / scale) * width + (i / scale)] * panel.gain
pixels[(top + j) * outW + left + i] = if (panel.heat) heat(v) else grey(v)
}
}
// A rule round each panel, so a black panel is still visibly a panel.
for (i in -1..panelW) {
pixels.setSafe(outW, outH, left + i, top - 1, RULE)
pixels.setSafe(outW, outH, left + i, top + panelH, RULE)
}
for (j in -1..panelH) {
pixels.setSafe(outW, outH, left - 1, top + j, RULE)
pixels.setSafe(outW, outH, left + panelW, top + j, RULE)
}
drawText(pixels, outW, outH, panel.label, left, pad, 2, LABEL)
}
return PngEncoder.encode(pixels, outW, outH)
}
A 0..1 value as an opaque grey.
private fun grey(v: Double): Int {
val level = (v.coerceIn(0.0, 1.0) * 255.0 + 0.5).toInt()
return (0xFF shl 24) or (level shl 16) or (level shl 8) or level
}
A 0..1 value on a black-red-yellow-white ramp.
Chosen over a perceptual colormap deliberately: this panel is read for where is it bright, and a ramp whose luminance rises monotonically from black answers that at a glance even in a thumbnail.
private fun heat(v: Double): Int {
val u = v.coerceIn(0.0, 1.0)
val r = (255.0 * (u * 3.0).coerceAtMost(1.0)).toInt()
val g = (255.0 * ((u - 1.0 / 3.0) * 3.0).coerceIn(0.0, 1.0)).toInt()
val b = (255.0 * ((u - 2.0 / 3.0) * 3.0).coerceIn(0.0, 1.0)).toInt()
return (0xFF shl 24) or (r shl 16) or (g shl 8) or b
}
PLOT_GLYPHS at scale pixels per dot, top-left at (x, y).
The text is upper-cased first, because PLOT_GLYPHS carries A–Z and no lower case, and an
unmapped character falls back to a blank. The first version of this figure passed lower-case
labels and rendered a panel captioned with nothing but its number — which is exactly the kind of
defect only looking at the picture finds.
private fun drawText(
pixels: IntArray,
width: Int,
height: Int,
text: String,
x: Int,
y: Int,
scale: Int,
colour: Int
) {
var cursor = x
for (ch in text.uppercase()) {
val glyph = PLOT_GLYPHS[ch] ?: PLOT_GLYPHS[' ']!!
for (row in glyph.indices) {
for (col in glyph[row].indices) {
if (glyph[row][col] != '#') continue
for (dy in 0 until scale) {
for (dx in 0 until scale) {
pixels.setSafe(width, height, cursor + col * scale + dx, y + row * scale + dy, colour)
}
}
}
}
cursor += 4 * scale
}
}
private fun IntArray.setSafe(width: Int, height: Int, x: Int, y: Int, colour: Int) {
if (x < 0 || y < 0 || x >= width || y >= height) return
this[y * width + x] = colour
}
}