mirror of
https://github.com/Dadido3/noita-mapcap.git
synced 2024-12-22 02:17:33 +00:00
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:
parent
7a4dbeddf1
commit
3a73e13fb7
200
bin/stitch/blend-functions.go
Normal file
200
bin/stitch/blend-functions.go
Normal 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})
|
||||
}
|
||||
}
|
||||
}
|
@ -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
159
bin/stitch/image-tile.go
Normal 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
162
bin/stitch/image-tiles.go
Normal 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
|
||||
}
|
@ -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)
|
||||
}
|
@ -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
|
||||
}
|
@ -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 {
|
@ -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
|
||||
}
|
@ -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.
|
||||
}
|
||||
|
174
bin/stitch/stitched-image.go
Normal file
174
bin/stitch/stitched-image.go
Normal 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
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user