noita-mapcap/bin/stitch/stitched-image.go

167 lines
5.5 KiB
Go

// Copyright (c) 2022-2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"fmt"
"image"
"image/color"
"sync/atomic"
"time"
)
// The default background color.
// We use a non transparent black.
var colorBackground = color.RGBA{0, 0, 0, 255}
// StitchedImageCacheGridSize defines the worker chunk size when the cache image is regenerated.
var StitchedImageCacheGridSize = 256
// StitchedImageBlendMethod defines how tiles are blended together.
type StitchedImageBlendMethod interface {
Draw(tiles []*ImageTile, destImage *image.RGBA) // Draw is called when a new cache image is generated.
}
// StitchedImageOverlay defines an interface for arbitrary overlays that can be drawn over the stitched image.
type StitchedImageOverlay interface {
Draw(*image.RGBA)
}
// StitchedImage combines several ImageTile objects into a single RGBA image.
// The way the images are combined/blended is defined by the blendFunc.
type StitchedImage struct {
tiles ImageTiles
bounds image.Rectangle
blendMethod StitchedImageBlendMethod
overlays []StitchedImageOverlay
cacheRowHeight int
cacheRows []StitchedImageCache
cacheRowYOffset int // Defines the pixel offset of the first cache row.
oldCacheRowIndex int
queryCounter atomic.Int64
}
// NewStitchedImage creates a new image from several single image tiles.
func NewStitchedImage(tiles ImageTiles, bounds image.Rectangle, blendMethod StitchedImageBlendMethod, cacheRowHeight int, overlays []StitchedImageOverlay) (*StitchedImage, error) {
if bounds.Empty() {
return nil, fmt.Errorf("given boundaries are empty")
}
if blendMethod == nil {
return nil, fmt.Errorf("no blending method given")
}
if cacheRowHeight <= 0 {
return nil, fmt.Errorf("invalid cache row height of %d pixels", cacheRowHeight)
}
stitchedImage := &StitchedImage{
tiles: tiles,
bounds: bounds,
blendMethod: blendMethod,
overlays: overlays,
}
// Generate cache image rows.
maxRow := (bounds.Dy() - 1) / cacheRowHeight
var cacheRows []StitchedImageCache
for i := 0; i <= maxRow; i++ {
rect := image.Rect(bounds.Min.X, bounds.Min.Y+i*cacheRowHeight, bounds.Max.X, bounds.Min.Y+(i+1)*cacheRowHeight)
cacheRows = append(cacheRows, NewStitchedImageCache(stitchedImage, rect.Intersect(bounds)))
}
stitchedImage.cacheRowHeight = cacheRowHeight
stitchedImage.cacheRowYOffset = -bounds.Min.Y
stitchedImage.cacheRows = cacheRows
// Start ticker to automatically invalidate caches.
// Due to this, the stitchedImage object is not composable, as this goroutine will always have a reference.
go func() {
ticker := time.NewTicker(1 * time.Second)
for range ticker.C {
for rowIndex := range stitchedImage.cacheRows {
stitchedImage.cacheRows[rowIndex].InvalidateAuto(3) // Invalidate cache row after 3 seconds of being idle.
}
}
}()
return stitchedImage, nil
}
// ColorModel returns the Image's color model.
func (si *StitchedImage) ColorModel() color.Model {
return color.RGBAModel
}
// Bounds returns the domain for which At can return non-zero color.
// The bounds do not necessarily contain the point (0, 0).
func (si *StitchedImage) Bounds() image.Rectangle {
return si.bounds
}
func (si *StitchedImage) At(x, y int) color.Color {
return si.RGBAAt(x, y)
}
// At returns the color of the pixel at (x, y).
//
// This is optimized to be read line by line (scanning), it will be much slower with random access.
//
// For the `Progress()` method to work correctly, every pixel should be queried exactly once.
//
// At(Bounds().Min.X, Bounds().Min.Y) // returns the top-left pixel of the image.
// At(Bounds().Max.X-1, Bounds().Max.Y-1) // returns the bottom-right pixel.
func (si *StitchedImage) RGBAAt(x, y int) color.RGBA {
// Assume that every pixel is only queried once.
si.queryCounter.Add(1)
// Determine the cache rowIndex index.
rowIndex := (y + si.cacheRowYOffset) / si.cacheRowHeight
if rowIndex < 0 || rowIndex >= len(si.cacheRows) {
return colorBackground
}
// Check if we advanced/changed the row index.
// This doesn't happen a lot, so stuff inside this can be a bit more expensive.
if si.oldCacheRowIndex != rowIndex {
// Pre generate the new row asynchronously.
newRowIndex := rowIndex + 1
if newRowIndex >= 0 && newRowIndex < len(si.cacheRows) {
go si.cacheRows[newRowIndex].Regenerate()
}
// Invalidate all tiles that are above the next row.
si.tiles.InvalidateAboveY((rowIndex+1)*si.cacheRowHeight - si.cacheRowYOffset)
si.oldCacheRowIndex = rowIndex
}
return si.cacheRows[rowIndex].RGBAAt(x, y)
}
// Opaque returns whether the image is fully opaque.
//
// For more speed and smaller file size, StitchedImage will be marked as non-transparent.
// This will speed up image saving by 2x, as there is no need to iterate over the whole image just to find a single non opaque pixel.
func (si *StitchedImage) Opaque() bool {
return true
}
// Progress returns the approximate progress of any process that scans the image from top to bottom.
func (si *StitchedImage) Progress() (value, max int) {
size := si.Bounds().Size()
return int(si.queryCounter.Load()), size.X * size.Y
}
// SubStitchedImage returns an image representing the portion of the image p visible through r.
// The returned image references to the original stitched image, and therefore reuses its cache.
func (si *StitchedImage) SubStitchedImage(r image.Rectangle) SubStitchedImage {
return SubStitchedImage{
StitchedImage: si,
bounds: si.Bounds().Intersect(r),
}
}