Refactor and improve stitcher

- Replace MedianBlendedImage with StitchedImage, a general implementation of a stitcher
- Don't use hilbert curve when regenerating cache image
- Cut workload rectangles to be always inside the cache image boundaries
- Rename stitch.go to main.go
- Add interface for overlays
- Change how overlays are handled and drawn
- Reduce error returns to simplify a lot of code
- Add several blend functions
- Remove offset field from image tile
This commit is contained in:
David Vogel 2022-08-11 11:10:07 +02:00
parent 7a4dbeddf1
commit 3a73e13fb7
11 changed files with 781 additions and 611 deletions

View File

@ -0,0 +1,200 @@
// Copyright (c) 2022 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"image"
"image/color"
"sort"
)
// BlendFuncMedian takes the given tiles and median blends them into destImage.
func BlendFuncMedian(tiles []*ImageTile, destImage *image.RGBA) {
bounds := destImage.Bounds()
// List of images corresponding with every tile.
// Can contain empty/nil entries for images that failed to load.
images := []*image.RGBA{}
for _, tile := range tiles {
images = append(images, tile.GetImage())
}
// Create arrays to be reused every pixel.
rListEmpty, gListEmpty, bListEmpty := make([]int, 0, len(tiles)), make([]int, 0, len(tiles)), make([]int, 0, len(tiles))
for iy := bounds.Min.Y; iy < bounds.Max.Y; iy++ {
for ix := bounds.Min.X; ix < bounds.Max.X; ix++ {
rList, gList, bList := rListEmpty, gListEmpty, bListEmpty
point := image.Point{ix, iy}
found := false
// Iterate through all images and create a list of colors.
for _, img := range images {
if img != nil {
if point.In(img.Bounds()) {
col := img.RGBAAt(point.X, point.Y)
rList, gList, bList = append(rList, int(col.R)), append(gList, int(col.G)), append(bList, int(col.B))
found = true
}
}
}
// If there were no images to get data from, ignore the pixel.
if !found {
continue
}
// Sort colors.
sort.Ints(rList)
sort.Ints(gList)
sort.Ints(bList)
// Take the middle element of each color.
var r, g, b uint8
if l := len(rList); l%2 == 0 {
// Even.
r = uint8((rList[l/2-1] + rList[l/2]) / 2)
} else {
// Odd.
r = uint8(rList[(l-1)/2])
}
if l := len(gList); l%2 == 0 {
// Even.
g = uint8((gList[l/2-1] + gList[l/2]) / 2)
} else {
// Odd.
g = uint8(gList[(l-1)/2])
}
if l := len(bList); l%2 == 0 {
// Even.
b = uint8((bList[l/2-1] + bList[l/2]) / 2)
} else {
// Odd.
b = uint8(bList[(l-1)/2])
}
destImage.SetRGBA(ix, iy, color.RGBA{r, g, b, 255})
}
}
}
// BlendNewestPixel takes the given tiles and only draws the newest pixel (based on file modification time) of any overlapping tiles.
func BlendNewestPixel(tiles []*ImageTile, destImage *image.RGBA) {
bounds := destImage.Bounds()
// Sort tiles by date.
sort.Slice(tiles, func(i, j int) bool { return tiles[i].modTime.After(tiles[j].modTime) })
// List of images corresponding with every tile.
// Can contain empty/nil entries for images that failed to load.
images := []*image.RGBA{}
for _, tile := range tiles {
images = append(images, tile.GetImage())
}
for iy := bounds.Min.Y; iy < bounds.Max.Y; iy++ {
for ix := bounds.Min.X; ix < bounds.Max.X; ix++ {
point := image.Point{ix, iy}
found := false
// Look for first valid pixel in stack of tiles.
var col color.RGBA
for _, img := range images {
if img != nil {
if point.In(img.Bounds()) {
col = img.RGBAAt(point.X, point.Y)
found = true
break
}
}
}
// If there were no images to get data from, ignore the pixel.
if !found {
continue
}
destImage.SetRGBA(ix, iy, col)
}
}
}
// BlendNewestPixelsMedian takes the given tiles and median blends the n newest pixels (based on file modification time) of any overlapping tiles.
// n is some hardcoded value inside this function.
func BlendNewestPixelsMedian(tiles []*ImageTile, destImage *image.RGBA) {
bounds := destImage.Bounds()
// Sort tiles by date.
sort.Slice(tiles, func(i, j int) bool { return tiles[i].modTime.After(tiles[j].modTime) })
// List of images corresponding with every tile.
// Can contain empty/nil entries for images that failed to load.
images := []*image.RGBA{}
for _, tile := range tiles {
images = append(images, tile.GetImage())
}
// Create arrays to be reused every pixel.
rListEmpty, gListEmpty, bListEmpty := make([]int, 0, len(tiles)), make([]int, 0, len(tiles)), make([]int, 0, len(tiles))
for iy := bounds.Min.Y; iy < bounds.Max.Y; iy++ {
for ix := bounds.Min.X; ix < bounds.Max.X; ix++ {
rList, gList, bList := rListEmpty, gListEmpty, bListEmpty
point := image.Point{ix, iy}
count := 0
// Iterate through all images and create a list of colors.
for _, img := range images {
if img != nil {
if point.In(img.Bounds()) {
col := img.RGBAAt(point.X, point.Y)
rList, gList, bList = append(rList, int(col.R)), append(gList, int(col.G)), append(bList, int(col.B))
count++
if count == 9 { // Max. number of tiles to median blend.
break
}
}
}
}
// If there were no images to get data from, ignore the pixel.
if count == 0 {
continue
}
// Sort colors.
sort.Ints(rList)
sort.Ints(gList)
sort.Ints(bList)
// Take the middle element of each color.
var r, g, b uint8
if l := len(rList); l%2 == 0 {
// Even.
r = uint8((rList[l/2-1] + rList[l/2]) / 2)
} else {
// Odd.
r = uint8(rList[(l-1)/2])
}
if l := len(gList); l%2 == 0 {
// Even.
g = uint8((gList[l/2-1] + gList[l/2]) / 2)
} else {
// Odd.
g = uint8(gList[(l-1)/2])
}
if l := len(bList); l%2 == 0 {
// Even.
b = uint8((bList[l/2-1] + bList[l/2]) / 2)
} else {
// Odd.
b = uint8(bList[(l-1)/2])
}
destImage.SetRGBA(ix, iy, color.RGBA{r, g, b, 255})
}
}
}

View File

@ -7,10 +7,12 @@ package main
import (
"encoding/json"
"image"
"image/color"
"os"
"github.com/tdewolff/canvas"
"github.com/tdewolff/canvas/renderers/rasterizer"
)
//var entityDisplayFontFamily = canvas.NewFontFamily("times")
@ -103,13 +105,15 @@ type Component struct {
Members map[string]any `json:"members"`
}
func loadEntities(path string) ([]Entity, error) {
type Entities []Entity
func LoadEntities(path string) (Entities, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
var result []Entity
var result Entities
jsonDec := json.NewDecoder(file)
if err := jsonDec.Decode(&result); err != nil {
@ -236,3 +240,32 @@ func (e Entity) Draw(c *canvas.Context) {
//text := canvas.NewTextLine(entityDisplayFontFace, fmt.Sprintf("%s\n%s", e.Name, e.Filename), canvas.Left)
//c.DrawText(x, y, text)
}
func (e Entities) Draw(destImage *image.RGBA) {
destRect := destImage.Bounds()
// Same as destImage, but top left is translated to (0, 0).
originImage := destImage.SubImage(destRect).(*image.RGBA)
originImage.Rect = originImage.Rect.Sub(destRect.Min)
c := canvas.New(float64(destRect.Dx()), float64(destRect.Dy()))
ctx := canvas.NewContext(c)
ctx.SetCoordSystem(canvas.CartesianIV)
ctx.SetCoordRect(canvas.Rect{X: -float64(destRect.Min.X), Y: -float64(destRect.Min.Y), W: float64(destRect.Dx()), H: float64(destRect.Dy())}, float64(destRect.Dx()), float64(destRect.Dy()))
// Set drawing style.
ctx.Style = playerPathDisplayStyle
for _, entity := range e {
// Check if entity origin is near or around the current image rectangle.
entityOrigin := image.Point{int(entity.Transform.X), int(entity.Transform.Y)}
if entityOrigin.In(destRect.Inset(-512)) {
entity.Draw(ctx)
}
}
// Theoretically we would need to linearize imgRGBA first, but DefaultColorSpace assumes that the color space is linear already.
r := rasterizer.FromImage(originImage, canvas.DPMM(1.0), canvas.DefaultColorSpace)
c.Render(r)
r.Close() // This just transforms the image's luminance curve back from linear into non linear.
}

159
bin/stitch/image-tile.go Normal file
View File

@ -0,0 +1,159 @@
// Copyright (c) 2019-2022 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"fmt"
"image"
_ "image/png"
"log"
"os"
"path/filepath"
"regexp"
"strconv"
"sync"
"time"
"github.com/nfnt/resize"
)
var ImageTileFileRegex = regexp.MustCompile(`^(-?\d+),(-?\d+).png$`)
type ImageTile struct {
fileName string
modTime time.Time
scaleDivider int // Downscales the coordinates and images on the fly.
image image.Image // Either a rectangle or an RGBA image. The bounds of this image are determined by the filename.
imageMutex *sync.RWMutex //
imageUsedFlag bool // Flag signalling, that the image was used recently.
pixelErrorSum uint64 // Sum of the difference between the (sub)pixels of all overlapping images. 0 Means that all overlapping images are identical.
}
// NewImageTile returns an image tile object that represents the image at the given path.
// This will not load the image into RAM.
func NewImageTile(path string, scaleDivider int) (ImageTile, error) {
if scaleDivider < 1 {
return ImageTile{}, fmt.Errorf("invalid scale of %v", scaleDivider)
}
baseName := filepath.Base(path)
result := ImageTileFileRegex.FindStringSubmatch(baseName)
var x, y int
if parsed, err := strconv.ParseInt(result[1], 10, 0); err == nil {
x = int(parsed)
} else {
return ImageTile{}, fmt.Errorf("error parsing %q to integer: %w", result[1], err)
}
if parsed, err := strconv.ParseInt(result[2], 10, 0); err == nil {
y = int(parsed)
} else {
return ImageTile{}, fmt.Errorf("error parsing %q to integer: %w", result[2], err)
}
width, height, err := getImageFileDimension(path)
if err != nil {
return ImageTile{}, err
}
var modTime time.Time
fileInfo, err := os.Lstat(path)
if err == nil {
modTime = fileInfo.ModTime()
}
return ImageTile{
fileName: path,
modTime: modTime,
scaleDivider: scaleDivider,
image: image.Rect(x/scaleDivider, y/scaleDivider, (x+width)/scaleDivider, (y+height)/scaleDivider),
imageMutex: &sync.RWMutex{},
}, nil
}
// GetImage returns an image.Image that contains the tile pixel data.
// This will not return errors in case something went wrong, but will just return nil.
// All errors are written to stdout.
func (it *ImageTile) GetImage() *image.RGBA {
it.imageMutex.RLock()
it.imageUsedFlag = true // Race condition may happen on this flag, but doesn't matter here.
// Check if the image is already loaded.
if img, ok := it.image.(*image.RGBA); ok {
it.imageMutex.RUnlock()
return img
}
it.imageMutex.RUnlock()
// It's possible that the image got changed in between here.
it.imageMutex.Lock()
defer it.imageMutex.Unlock()
// Check again if the image is already loaded.
if img, ok := it.image.(*image.RGBA); ok {
return img
}
// Store rectangle of the old image.
oldRect := it.image.Bounds()
file, err := os.Open(it.fileName)
if err != nil {
log.Printf("Couldn't load file %q: %v.", it.fileName, err)
return nil
}
defer file.Close()
img, _, err := image.Decode(file)
if err != nil {
log.Printf("Couldn't decode image %q: %v.", it.fileName, err)
return nil
}
if it.scaleDivider > 1 {
img = resize.Resize(uint(oldRect.Dx()), uint(oldRect.Dy()), img, resize.NearestNeighbor)
}
imgRGBA, ok := img.(*image.RGBA)
if !ok {
log.Printf("Expected an RGBA image for %q, got %T instead.", it.fileName, img)
return nil
}
imgRGBA.Rect = imgRGBA.Rect.Add(oldRect.Min)
it.image = imgRGBA
// Free the image after some time.
go func() {
for it.imageUsedFlag {
it.imageUsedFlag = false
time.Sleep(1000 * time.Millisecond)
}
it.imageMutex.Lock()
defer it.imageMutex.Unlock()
it.image = it.image.Bounds()
}()
return imgRGBA
}
// The scaled image boundaries.
// This matches exactly to what GetImage() returns.
func (it *ImageTile) Bounds() image.Rectangle {
it.imageMutex.RLock()
defer it.imageMutex.RUnlock()
return it.image.Bounds()
}
func (it *ImageTile) String() string {
return fmt.Sprintf("{ImageTile: %q}", it.fileName)
}

162
bin/stitch/image-tiles.go Normal file
View File

@ -0,0 +1,162 @@
// Copyright (c) 2019-2022 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"fmt"
"image"
"path/filepath"
"runtime"
"sync"
"github.com/cheggaaa/pb/v3"
)
// LoadImageTiles "loads" all images in the directory at the given path.
func LoadImageTiles(path string, scaleDivider int) ([]ImageTile, error) {
if scaleDivider < 1 {
return nil, fmt.Errorf("invalid scale of %v", scaleDivider)
}
var imageTiles []ImageTile
files, err := filepath.Glob(filepath.Join(path, "*.png"))
if err != nil {
return nil, err
}
for _, file := range files {
imageTile, err := NewImageTile(file, scaleDivider)
if err != nil {
return nil, err
}
imageTiles = append(imageTiles, imageTile)
}
return imageTiles, nil
}
// Compare takes a list of tiles and compares them pixel by pixel.
// The resulting pixel difference sum is stored in each tile.
func Compare(tiles []ImageTile, bounds image.Rectangle) error {
intersectTiles := []*ImageTile{}
images := []*image.RGBA{}
// Get only the tiles that intersect with the bounds.
// Ignore alignment here, doesn't matter if an image overlaps a few pixels anyways.
for i, tile := range tiles {
if tile.Bounds().Overlaps(bounds) {
tilePtr := &tiles[i]
img := tilePtr.GetImage()
if img == nil {
continue
}
intersectTiles = append(intersectTiles, tilePtr)
imgCopy := *img
//imgCopy.Rect = imgCopy.Rect
images = append(images, &imgCopy)
}
}
tempTilesEmpty := make([]*ImageTile, 0, len(intersectTiles))
for iy := bounds.Min.Y; iy < bounds.Max.Y; iy++ {
for ix := bounds.Min.X; ix < bounds.Max.X; ix++ {
var rMin, rMax, gMin, gMax, bMin, bMax uint8
point := image.Point{ix, iy}
found := false
tempTiles := tempTilesEmpty
// Iterate through all images and find min and max subpixel values.
for i, img := range images {
if point.In(img.Bounds()) {
tempTiles = append(tempTiles, intersectTiles[i])
col := img.RGBAAt(point.X, point.Y)
if !found {
found = true
rMin, rMax, gMin, gMax, bMin, bMax = col.R, col.R, col.G, col.G, col.B, col.B
} else {
if rMin > col.R {
rMin = col.R
}
if rMax < col.R {
rMax = col.R
}
if gMin > col.G {
gMin = col.G
}
if gMax < col.G {
gMax = col.G
}
if bMin > col.B {
bMin = col.B
}
if bMax < col.B {
bMax = col.B
}
}
}
}
// If there were no images to get data from, ignore the pixel.
if !found {
continue
}
// Write the error value back into the tiles (Only those that contain the point point)
for _, tile := range tempTiles {
tile.pixelErrorSum += uint64(rMax-rMin) + uint64(gMax-gMin) + uint64(bMax-bMin)
}
}
}
return nil
}
// CompareGrid calls Compare, but divides the workload into a grid of chunks.
// Additionally it runs the workload multithreaded.
func CompareGrid(tiles []ImageTile, bounds image.Rectangle, gridSize int, bar *pb.ProgressBar) (errResult error) {
//workloads := gridifyRectangle(destImage.Bounds(), gridSize)
workloads, err := hilbertifyRectangle(bounds, gridSize)
if err != nil {
return err
}
if bar != nil {
bar.SetTotal(int64(len(workloads))).Start()
}
// Start worker threads
wc := make(chan image.Rectangle)
wg := sync.WaitGroup{}
for i := 0; i < runtime.NumCPU()*2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for workload := range wc {
if err := Compare(tiles, workload); err != nil {
errResult = err // This will not stop execution, but at least one of any errors is returned.
}
if bar != nil {
bar.Increment()
}
}
}()
}
// Push workload to worker threads
for _, workload := range workloads {
wc <- workload
}
// Wait until all worker threads are done
close(wc)
wg.Wait()
return
}

View File

@ -1,156 +0,0 @@
// Copyright (c) 2019-2022 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"fmt"
"image"
_ "image/png"
"os"
"sync"
"time"
"github.com/nfnt/resize"
"github.com/tdewolff/canvas"
"github.com/tdewolff/canvas/renderers/rasterizer"
)
type imageTile struct {
fileName string
scaleDivider int // Downscales the coordinates and images on the fly.
offset image.Point // Correction offset of the image, so that it aligns pixel perfect with other images. Determined by image matching.
image image.Image // Either a rectangle or an RGBA image. The bounds of this image are determined by the filename.
imageMutex *sync.RWMutex //
imageUsedFlag bool // Flag signalling, that the image was used recently.
pixelErrorSum uint64 // Sum of the difference between the (sub)pixels of all overlapping images. 0 Means that all overlapping images are identical.
entities []Entity // List of entities that may lie on or near this image tile.
playerPath *PlayerPath // Contains the player path.
}
func (it *imageTile) GetImage() (*image.RGBA, error) {
it.imageMutex.RLock()
it.imageUsedFlag = true // Race condition may happen on this flag, but doesn't matter here.
// Check if the image is already loaded.
if img, ok := it.image.(*image.RGBA); ok {
it.imageMutex.RUnlock()
return img, nil
}
it.imageMutex.RUnlock()
// It's possible that the image got changed in between here.
it.imageMutex.Lock()
defer it.imageMutex.Unlock()
// Check again if the image is already loaded.
if img, ok := it.image.(*image.RGBA); ok {
return img, nil
}
// Store rectangle of the old image.
oldRect := it.image.Bounds()
file, err := os.Open(it.fileName)
if err != nil {
return &image.RGBA{}, err
}
defer file.Close()
img, _, err := image.Decode(file)
if err != nil {
return &image.RGBA{}, err
}
if it.scaleDivider > 1 {
img = resize.Resize(uint(oldRect.Dx()), uint(oldRect.Dy()), img, resize.NearestNeighbor)
}
imgRGBA, ok := img.(*image.RGBA)
if !ok {
return &image.RGBA{}, fmt.Errorf("expected an RGBA image, got %T instead", img)
}
scaledRect := imgRGBA.Rect.Add(oldRect.Min)
// Draw entities.
// tdewolff/canvas doesn't respect the image boundaries, so we have to draw on the image before we move its rectangle.
if len(it.entities) > 0 {
c := canvas.New(float64(imgRGBA.Rect.Dx()), float64(imgRGBA.Rect.Dy()))
ctx := canvas.NewContext(c)
ctx.SetCoordSystem(canvas.CartesianIV)
ctx.SetCoordRect(canvas.Rect{X: -float64(oldRect.Min.X), Y: -float64(oldRect.Min.Y), W: float64(imgRGBA.Rect.Dx()), H: float64(imgRGBA.Rect.Dy())}, float64(imgRGBA.Rect.Dx()), float64(imgRGBA.Rect.Dy()))
for _, entity := range it.entities {
// Check if entity origin is near or around the current image rectangle.
entityOrigin := image.Point{int(entity.Transform.X), int(entity.Transform.Y)}
if entityOrigin.In(scaledRect.Inset(-512)) {
entity.Draw(ctx)
}
}
// Theoretically we would need to linearize imgRGBA first, but DefaultColorSpace assumes that the color space is linear already.
r := rasterizer.FromImage(imgRGBA, canvas.DPMM(1.0), canvas.DefaultColorSpace)
c.Render(r)
r.Close() // This just transforms the image's luminance curve back from linear into non linear.
}
// Draw player path.
if it.playerPath != nil {
c := canvas.New(float64(imgRGBA.Rect.Dx()), float64(imgRGBA.Rect.Dy()))
ctx := canvas.NewContext(c)
ctx.SetCoordSystem(canvas.CartesianIV)
ctx.SetCoordRect(canvas.Rect{X: -float64(oldRect.Min.X), Y: -float64(oldRect.Min.Y), W: float64(imgRGBA.Rect.Dx()), H: float64(imgRGBA.Rect.Dy())}, float64(imgRGBA.Rect.Dx()), float64(imgRGBA.Rect.Dy()))
it.playerPath.Draw(ctx, scaledRect)
// Theoretically we would need to linearize imgRGBA first, but DefaultColorSpace assumes that the color space is linear already.
r := rasterizer.FromImage(imgRGBA, canvas.DPMM(1.0), canvas.DefaultColorSpace)
c.Render(r)
r.Close() // This just transforms the image's luminance curve back from linear into non linear.
}
// Restore the position of the image rectangle.
imgRGBA.Rect = scaledRect
it.image = imgRGBA
// Free the image after some time.
go func() {
for it.imageUsedFlag {
it.imageUsedFlag = false
time.Sleep(500 * time.Millisecond)
}
it.imageMutex.Lock()
defer it.imageMutex.Unlock()
it.image = it.image.Bounds()
}()
return imgRGBA, nil
}
func (it *imageTile) OffsetBounds() image.Rectangle {
it.imageMutex.RLock()
defer it.imageMutex.RUnlock()
return it.image.Bounds().Add(it.offset)
}
func (it *imageTile) Bounds() image.Rectangle {
it.imageMutex.RLock()
defer it.imageMutex.RUnlock()
return it.image.Bounds()
}
func (it *imageTile) String() string {
return fmt.Sprintf("<ImageTile \"%v\">", it.fileName)
}

View File

@ -1,333 +0,0 @@
// Copyright (c) 2019-2022 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"fmt"
"image"
"image/color"
"log"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"sync"
"github.com/cheggaaa/pb/v3"
)
var regexFileParse = regexp.MustCompile(`^(-?\d+),(-?\d+).png$`)
func loadImages(path string, entities []Entity, playerPath *PlayerPath, scaleDivider int) ([]imageTile, error) {
var imageTiles []imageTile
if scaleDivider < 1 {
return nil, fmt.Errorf("invalid scale of %v", scaleDivider)
}
files, err := filepath.Glob(filepath.Join(path, "*.png"))
if err != nil {
return nil, err
}
for _, file := range files {
baseName := filepath.Base(file)
result := regexFileParse.FindStringSubmatch(baseName)
var x, y int
if parsed, err := strconv.ParseInt(result[1], 10, 0); err == nil {
x = int(parsed)
} else {
return nil, fmt.Errorf("error parsing %v to integer: %w", result[1], err)
}
if parsed, err := strconv.ParseInt(result[2], 10, 0); err == nil {
y = int(parsed)
} else {
return nil, fmt.Errorf("error parsing %v to integer: %w", result[2], err)
}
width, height, err := getImageFileDimension(file)
if err != nil {
return nil, err
}
imageTiles = append(imageTiles, imageTile{
fileName: file,
scaleDivider: scaleDivider,
image: image.Rect(x/scaleDivider, y/scaleDivider, (x+width)/scaleDivider, (y+height)/scaleDivider),
imageMutex: &sync.RWMutex{},
entities: entities,
playerPath: playerPath,
})
}
return imageTiles, nil
}
// Stitch takes a list of tiles and stitches them together.
// The destImage shouldn't be too large, or it gets too slow.
func Stitch(tiles []imageTile, destImage *image.RGBA) error {
//intersectTiles := []*imageTile{}
images := []*image.RGBA{}
// Get only the tiles that intersect with the destination image bounds.
// Ignore alignment here, doesn't matter if an image overlaps a few pixels anyways.
for i, tile := range tiles {
if tile.OffsetBounds().Overlaps(destImage.Bounds()) {
tilePtr := &tiles[i]
img, err := tilePtr.GetImage()
if err != nil {
log.Printf("couldn't load image tile %s: %v", tile.String(), err)
continue
}
//intersectTiles = append(intersectTiles, tilePtr)
imgCopy := *img
imgCopy.Rect = imgCopy.Rect.Add(tile.offset)
images = append(images, &imgCopy)
}
}
//log.Printf("intersectTiles: %v", intersectTiles)
/*for _, intersectTile := range intersectTiles {
intersectTile.loadImage()
draw.Draw(destImage, destImage.Bounds(), intersectTile.image, destImage.Bounds().Min, draw.Over)
}*/
/*for _, intersectTile := range intersectTiles {
drawLabel(destImage, intersectTile.image.Bounds().Min.X, intersectTile.image.Bounds().Min.Y, fmt.Sprintf("%v", intersectTile.fileName))
}*/
drawMedianBlended(images, destImage)
return nil
}
// StitchGrid calls Stitch, but divides the workload into a grid of chunks.
// Additionally it runs the workload multithreaded.
func StitchGrid(tiles []imageTile, destImage *image.RGBA, gridSize int, bar *pb.ProgressBar) (errResult error) {
//workloads := gridifyRectangle(destImage.Bounds(), gridSize)
workloads, err := hilbertifyRectangle(destImage.Bounds(), gridSize)
if err != nil {
return err
}
if bar != nil {
bar.SetTotal(int64(len(workloads))).Start()
}
// Start worker threads
wc := make(chan image.Rectangle)
wg := sync.WaitGroup{}
for i := 0; i < runtime.NumCPU()*2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for workload := range wc {
if err := Stitch(tiles, destImage.SubImage(workload).(*image.RGBA)); err != nil {
errResult = err // This will not stop execution, but at least one of any errors is returned.
}
if bar != nil {
bar.Increment()
}
}
}()
}
// Push workload to worker threads
for _, workload := range workloads {
wc <- workload
}
// Wait until all worker threads are done
close(wc)
wg.Wait()
return
}
func drawMedianBlended(images []*image.RGBA, destImage *image.RGBA) {
bounds := destImage.Bounds()
// Create arrays to be reused every pixel
rListEmpty, gListEmpty, bListEmpty := make([]int, 0, len(images)), make([]int, 0, len(images)), make([]int, 0, len(images))
for iy := bounds.Min.Y; iy < bounds.Max.Y; iy++ {
for ix := bounds.Min.X; ix < bounds.Max.X; ix++ {
rList, gList, bList := rListEmpty, gListEmpty, bListEmpty
point := image.Point{ix, iy}
found := false
// Iterate through all images and create a list of colors.
for _, img := range images {
if point.In(img.Bounds()) {
col := img.RGBAAt(point.X, point.Y)
rList, gList, bList = append(rList, int(col.R)), append(gList, int(col.G)), append(bList, int(col.B))
found = true
}
}
// If there were no images to get data from, ignore the pixel.
if !found {
//destImage.SetRGBA(ix, iy, color.RGBA{})
continue
}
// Sort colors.
sort.Ints(rList)
sort.Ints(gList)
sort.Ints(bList)
// Take the middle element of each color.
var r, g, b uint8
if len(rList)%2 == 0 {
// Even
r = uint8((rList[len(rList)/2-1] + rList[len(rList)/2]) / 2)
} else {
// Odd
r = uint8(rList[(len(rList)-1)/2])
}
if len(gList)%2 == 0 {
// Even
g = uint8((gList[len(gList)/2-1] + gList[len(gList)/2]) / 2)
} else {
// Odd
g = uint8(gList[(len(gList)-1)/2])
}
if len(bList)%2 == 0 {
// Even
b = uint8((bList[len(bList)/2-1] + bList[len(bList)/2]) / 2)
} else {
// Odd
b = uint8(bList[(len(bList)-1)/2])
}
destImage.SetRGBA(ix, iy, color.RGBA{r, g, b, 255})
}
}
}
// Compare takes a list of tiles and compares them pixel by pixel.
// The resulting pixel difference sum is stored in each tile.
func Compare(tiles []imageTile, bounds image.Rectangle) error {
intersectTiles := []*imageTile{}
images := []*image.RGBA{}
// Get only the tiles that intersect with the bounds.
// Ignore alignment here, doesn't matter if an image overlaps a few pixels anyways.
for i, tile := range tiles {
if tile.OffsetBounds().Overlaps(bounds) {
tilePtr := &tiles[i]
img, err := tilePtr.GetImage()
if err != nil {
log.Printf("Couldn't load image tile %s: %v", tile.String(), err)
continue
}
intersectTiles = append(intersectTiles, tilePtr)
imgCopy := *img
imgCopy.Rect = imgCopy.Rect.Add(tile.offset)
images = append(images, &imgCopy)
}
}
tempTilesEmpty := make([]*imageTile, 0, len(intersectTiles))
for iy := bounds.Min.Y; iy < bounds.Max.Y; iy++ {
for ix := bounds.Min.X; ix < bounds.Max.X; ix++ {
var rMin, rMax, gMin, gMax, bMin, bMax uint8
point := image.Point{ix, iy}
found := false
tempTiles := tempTilesEmpty
// Iterate through all images and find min and max subpixel values.
for i, img := range images {
if point.In(img.Bounds()) {
tempTiles = append(tempTiles, intersectTiles[i])
col := img.RGBAAt(point.X, point.Y)
if !found {
found = true
rMin, rMax, gMin, gMax, bMin, bMax = col.R, col.R, col.G, col.G, col.B, col.B
} else {
if rMin > col.R {
rMin = col.R
}
if rMax < col.R {
rMax = col.R
}
if gMin > col.G {
gMin = col.G
}
if gMax < col.G {
gMax = col.G
}
if bMin > col.B {
bMin = col.B
}
if bMax < col.B {
bMax = col.B
}
}
}
}
// If there were no images to get data from, ignore the pixel.
if !found {
continue
}
// Write the error value back into the tiles (Only those that contain the point point)
for _, tile := range tempTiles {
tile.pixelErrorSum += uint64(rMax-rMin) + uint64(gMax-gMin) + uint64(bMax-bMin)
}
}
}
return nil
}
// CompareGrid calls Compare, but divides the workload into a grid of chunks.
// Additionally it runs the workload multithreaded.
func CompareGrid(tiles []imageTile, bounds image.Rectangle, gridSize int, bar *pb.ProgressBar) (errResult error) {
//workloads := gridifyRectangle(destImage.Bounds(), gridSize)
workloads, err := hilbertifyRectangle(bounds, gridSize)
if err != nil {
return err
}
if bar != nil {
bar.SetTotal(int64(len(workloads))).Start()
}
// Start worker threads
wc := make(chan image.Rectangle)
wg := sync.WaitGroup{}
for i := 0; i < runtime.NumCPU()*2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for workload := range wc {
if err := Compare(tiles, workload); err != nil {
errResult = err // This will not stop execution, but at least one of any errors is returned.
}
if bar != nil {
bar.Increment()
}
}
}()
}
// Push workload to worker threads
for _, workload := range workloads {
wc <- workload
}
// Wait until all worker threads are done
close(wc)
wg.Wait()
return
}

View File

@ -36,6 +36,8 @@ func main() {
flag.Parse()
var overlays []StitchedImageOverlay
// Query the user, if there were no cmd arguments given.
if flag.NFlag() == 0 {
prompt := promptui.Prompt{
@ -94,12 +96,13 @@ func main() {
}
// Load entities if requested.
entities, err := loadEntities(*flagEntitiesInputPath)
entities, err := LoadEntities(*flagEntitiesInputPath)
if err != nil {
log.Printf("Failed to load entities: %v", err)
}
if len(entities) > 0 {
log.Printf("Got %v entities.", len(entities))
overlays = append(overlays, entities) // Add entities to overlay drawing list.
}
// Query the user, if there were no cmd arguments given.
@ -118,16 +121,17 @@ func main() {
}
// Load player path if requested.
playerPath, err := loadPlayerPath(*flagPlayerPathInputPath)
playerPath, err := LoadPlayerPath(*flagPlayerPathInputPath)
if err != nil {
log.Printf("Failed to load player path: %v", err)
}
if playerPath != nil && len(playerPath.PathElements) > 0 {
log.Printf("Got %v player path entries.", len(playerPath.PathElements))
if len(playerPath) > 0 {
log.Printf("Got %v player path entries.", len(playerPath))
overlays = append(overlays, playerPath) // Add player path to overlay drawing list.
}
log.Printf("Starting to read tile information at \"%v\"", *flagInputPath)
tiles, err := loadImages(*flagInputPath, entities, playerPath, *flagScaleDivider)
tiles, err := LoadImageTiles(*flagInputPath, *flagScaleDivider)
if err != nil {
log.Panic(err)
}
@ -266,15 +270,18 @@ func main() {
*flagOutputPath = result
}
var outputImage image.Image
bar := pb.Full.New(0)
var wg sync.WaitGroup
done := make(chan bool)
done := make(chan struct{})
tempImage := NewMedianBlendedImage(tiles, outputRect)
_, max := tempImage.Progress()
outputImage, err := NewStitchedImage(tiles, outputRect, BlendNewestPixelsMedian, 512, overlays)
if err != nil {
log.Panicf("NewStitchedImage() failed: %v", err)
}
_, max := outputImage.Progress()
bar.SetTotal(int64(max)).Start().SetRefreshRate(1 * time.Second)
// Query progress and draw progress bar.
wg.Add(1)
go func() {
defer wg.Done()
@ -283,19 +290,17 @@ func main() {
for {
select {
case <-done:
value, _ := tempImage.Progress()
value, _ := outputImage.Progress()
bar.SetCurrent(int64(value))
bar.Finish()
return
case <-ticker.C:
value, _ := tempImage.Progress()
value, _ := outputImage.Progress()
bar.SetCurrent(int64(value))
}
}
}()
outputImage = tempImage
log.Printf("Creating output file \"%v\"", *flagOutputPath)
f, err := os.Create(*flagOutputPath)
if err != nil {
@ -307,7 +312,7 @@ func main() {
log.Panic(err)
}
done <- true
done <- struct{}{}
wg.Wait()
if err := f.Close(); err != nil {

View File

@ -1,90 +0,0 @@
// Copyright (c) 2019-2020 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"image"
"image/color"
"log"
)
// MedianBlendedImageRowHeight defines the height of the cached output image.
const MedianBlendedImageRowHeight = 256
// MedianBlendedImage combines several imageTile to a single RGBA image.
type MedianBlendedImage struct {
tiles []imageTile
bounds image.Rectangle
cachedRow *image.RGBA
queryCounter int
}
// NewMedianBlendedImage creates a new image from several single image tiles.
func NewMedianBlendedImage(tiles []imageTile, bounds image.Rectangle) *MedianBlendedImage {
return &MedianBlendedImage{
tiles: tiles,
bounds: bounds,
cachedRow: &image.RGBA{},
}
}
// ColorModel returns the Image's color model.
func (mbi *MedianBlendedImage) 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 (mbi *MedianBlendedImage) Bounds() image.Rectangle {
return mbi.bounds
}
// At returns the color of the pixel at (x, y).
// At(Bounds().Min.X, Bounds().Min.Y) returns the upper-left pixel of the grid.
// At(Bounds().Max.X-1, Bounds().Max.Y-1) returns the lower-right one.
func (mbi *MedianBlendedImage) At(x, y int) color.Color {
p := image.Point{x, y}
// Assume that every pixel is only queried once
mbi.queryCounter++
if !p.In(mbi.cachedRow.Bounds()) {
// Need to create a new row image
rect := mbi.Bounds()
rect.Min.Y = divideFloor(y, MedianBlendedImageRowHeight) * MedianBlendedImageRowHeight
rect.Max.Y = rect.Min.Y + MedianBlendedImageRowHeight
if !p.In(rect) {
return color.RGBA{}
}
mbi.cachedRow = image.NewRGBA(rect)
// TODO: Don't use hilbert curve here
if err := StitchGrid(mbi.tiles, mbi.cachedRow, 512, nil); err != nil {
log.Printf("StitchGrid failed: %v", err)
return color.RGBA{}
}
}
return mbi.cachedRow.RGBAAt(x, y)
}
// Opaque returns whether the image is fully opaque.
//
// For more speed and smaller file size, MedianBlendedImage 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 to find a single non opaque pixel.
func (mbi *MedianBlendedImage) Opaque() bool {
return true
}
// Progress returns the approximate progress of any process that scans the image from top to bottom.
func (mbi *MedianBlendedImage) Progress() (value, max int) {
size := mbi.Bounds().Size()
return mbi.queryCounter, size.X * size.Y
}

View File

@ -13,6 +13,7 @@ import (
"os"
"github.com/tdewolff/canvas"
"github.com/tdewolff/canvas/renderers/rasterizer"
)
var playerPathDisplayStyle = canvas.Style{
@ -34,52 +35,66 @@ type PlayerPathElement struct {
Polymorphed bool `json:"polymorphed"`
}
type PlayerPath struct {
PathElements []PlayerPathElement
}
type PlayerPath []PlayerPathElement
func loadPlayerPath(path string) (*PlayerPath, error) {
func LoadPlayerPath(path string) (PlayerPath, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
var result []PlayerPathElement
var result PlayerPath
jsonDec := json.NewDecoder(file)
if err := jsonDec.Decode(&result); err != nil {
return nil, err
}
return &PlayerPath{PathElements: result}, nil
return result, nil
}
func (p PlayerPath) Draw(c *canvas.Context, imgRect image.Rectangle) {
// Set drawing style.
c.Style = playerPathDisplayStyle
func (p PlayerPath) Draw(destImage *image.RGBA) {
destRect := destImage.Bounds()
for _, pathElement := range p.PathElements {
// Same as destImage, but top left is translated to (0, 0).
originImage := destImage.SubImage(destRect).(*image.RGBA)
originImage.Rect = originImage.Rect.Sub(destRect.Min)
c := canvas.New(float64(destRect.Dx()), float64(destRect.Dy()))
ctx := canvas.NewContext(c)
ctx.SetCoordSystem(canvas.CartesianIV)
ctx.SetCoordRect(canvas.Rect{X: -float64(destRect.Min.X), Y: -float64(destRect.Min.Y), W: float64(destRect.Dx()), H: float64(destRect.Dy())}, float64(destRect.Dx()), float64(destRect.Dy()))
// Set drawing style.
ctx.Style = playerPathDisplayStyle
for _, pathElement := range p {
from, to := pathElement.From, pathElement.To
// Only draw if the path may cross the image rectangle.
pathRect := image.Rectangle{image.Point{int(from[0]), int(from[1])}, image.Point{int(to[0]), int(to[1])}}.Canon().Inset(int(-playerPathDisplayStyle.StrokeWidth) - 1)
if pathRect.Overlaps(imgRect) {
if pathRect.Overlaps(destRect) {
path := &canvas.Path{}
path.MoveTo(from[0], from[1])
path.LineTo(to[0], to[1])
if pathElement.Polymorphed {
// Set stroke color to typically polymorph color.
c.Style.StrokeColor = color.RGBA{127, 50, 83, 127}
ctx.Style.StrokeColor = color.RGBA{127, 50, 83, 127}
} else {
// Set stroke color depending on HP level.
hpFactor := math.Max(math.Min(pathElement.HP/pathElement.MaxHP, 1), 0)
hpFactorInv := 1 - hpFactor
r, g, b, a := uint8((0*hpFactor+1*hpFactorInv)*127), uint8((1*hpFactor+0*hpFactorInv)*127), uint8(0), uint8(127)
c.Style.StrokeColor = color.RGBA{r, g, b, a}
ctx.Style.StrokeColor = color.RGBA{r, g, b, a}
}
c.DrawPath(0, 0, path)
ctx.DrawPath(0, 0, path)
}
}
// Theoretically we would need to linearize imgRGBA first, but DefaultColorSpace assumes that the color space is linear already.
r := rasterizer.FromImage(originImage, canvas.DPMM(1.0), canvas.DefaultColorSpace)
c.Render(r)
r.Close() // This just transforms the image's luminance curve back from linear into non linear.
}

View File

@ -0,0 +1,174 @@
// Copyright (c) 2022 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"fmt"
"image"
"image/color"
"runtime"
"sync"
)
// StitchedImageCacheGridSize defines the worker chunk size when the cache image is regenerated.
// TODO: Find optimal grid size that works good for tiles with lots and few overlap
var StitchedImageCacheGridSize = 512
// StitchedImageBlendFunc implements how all the tiles are blended together.
// This is called when a new cache image needs to be generated.
type StitchedImageBlendFunc func(tiles []*ImageTile, destImage *image.RGBA)
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 []ImageTile
bounds image.Rectangle
blendFunc StitchedImageBlendFunc
overlays []StitchedImageOverlay
cacheHeight int
cacheImage *image.RGBA
queryCounter int
}
// NewStitchedImage creates a new image from several single image tiles.
func NewStitchedImage(tiles []ImageTile, bounds image.Rectangle, blendFunc StitchedImageBlendFunc, cacheHeight int, overlays []StitchedImageOverlay) (*StitchedImage, error) {
if bounds.Empty() {
return nil, fmt.Errorf("given boundaries are empty")
}
if blendFunc == nil {
return nil, fmt.Errorf("no blending function given")
}
if cacheHeight <= 0 {
return nil, fmt.Errorf("invalid cache height of %d pixels", cacheHeight)
}
return &StitchedImage{
tiles: tiles,
bounds: bounds,
blendFunc: blendFunc,
overlays: overlays,
cacheHeight: cacheHeight,
cacheImage: &image.RGBA{},
}, 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
}
// 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.
//
// This is not thread safe, don't call from several goroutines!
func (si *StitchedImage) At(x, y int) color.Color {
p := image.Point{x, y}
// Assume that every pixel is only queried once.
si.queryCounter++
// Check if cached image needs to be regenerated.
if !p.In(si.cacheImage.Bounds()) {
rect := si.Bounds()
// TODO: Redo how the cache image rect is generated
rect.Min.Y = divideFloor(y, si.cacheHeight) * si.cacheHeight
rect.Max.Y = rect.Min.Y + si.cacheHeight
si.regenerateCache(rect)
}
return si.cacheImage.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 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 si.queryCounter, size.X * size.Y
}
// regenerateCache will regenerate the cache image at the given rectangle.
func (si *StitchedImage) regenerateCache(rect image.Rectangle) {
cacheImage := image.NewRGBA(rect)
// List of tiles that intersect with the to be generated cache image.
intersectingTiles := []*ImageTile{}
for i, tile := range si.tiles {
if tile.Bounds().Overlaps(rect) {
tilePtr := &si.tiles[i]
intersectingTiles = append(intersectingTiles, tilePtr)
}
}
// Start worker threads.
workerQueue := make(chan image.Rectangle)
waitGroup := sync.WaitGroup{}
for i := 0; i < runtime.NumCPU(); i++ {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
for workload := range workerQueue {
// List of tiles that intersect with the workload chunk.
workloadTiles := []*ImageTile{}
// Get only the tiles that intersect with the destination image bounds.
for _, tile := range intersectingTiles {
if tile.Bounds().Overlaps(workload) {
workloadTiles = append(workloadTiles, tile)
}
}
// Blend tiles into image at the workload rectangle.
si.blendFunc(workloadTiles, cacheImage.SubImage(workload).(*image.RGBA))
}
}()
}
// Divide rect into chunks and push to workers.
for _, chunk := range gridifyRectangle(rect, StitchedImageCacheGridSize) {
workerQueue <- chunk
}
close(workerQueue)
// Wait until all worker threads are done.
waitGroup.Wait()
// Draw overlays.
for _, overlay := range si.overlays {
if overlay != nil {
overlay.Draw(cacheImage)
}
}
// Update cached image.
si.cacheImage = cacheImage
}

View File

@ -64,8 +64,9 @@ func gridifyRectangle(rect image.Rectangle, gridSize int) (result []image.Rectan
for y := divideFloor(rect.Min.Y, gridSize); y < divideCeil(rect.Max.Y, gridSize); y++ {
for x := divideFloor(rect.Min.X, gridSize); x < divideCeil(rect.Max.X, gridSize); x++ {
tempRect := image.Rect(x*gridSize, y*gridSize, (x+1)*gridSize, (y+1)*gridSize)
if tempRect.Overlaps(rect) {
result = append(result, tempRect)
intersection := tempRect.Intersect(rect)
if !intersection.Empty() {
result = append(result, intersection)
}
}
}