Improve stitching speed and resource usage

- Use QuickSelect algorithm for median filtering
- Use lists of uint8 instead of int for median filtering
- Fix GridifyRectangle
- Remove HilbertifyRectangle
- Add profiling.go
- Remove Profile.bat
- Add median blend tile-limit flag
- Print stitch duration
- Reduce StitchedImage cache image height
- Reduce StitchedImageCacheGridSize
- Improve StitchedImage caching
- Improve ImageTile caching
- Separate entity and entities
- Update stitcher README.md
- Add comments
This commit is contained in:
David Vogel 2022-08-12 01:06:22 +02:00
parent c3f841a4ff
commit 65f7cb4e60
15 changed files with 648 additions and 417 deletions

View File

@ -1 +0,0 @@
go tool pprof -http=: ./stitch.exe cpu.prof

View File

@ -30,6 +30,10 @@ example list of files:
- Or run the program with parameters: - Or run the program with parameters:
- `divide int` - `divide int`
A downscaling factor. 2 will produce an image with half the side lengths. Defaults to 1. A downscaling factor. 2 will produce an image with half the side lengths. Defaults to 1.
- `tile-limit int`
Limits median blending to the n newest tiles by file modification time.
If set to 0, all available tiles will be median blended.
If set to 1, only the newest tile will be used for any resulting pixel.
- `input string` - `input string`
The source path of the image tiles to be stitched. Defaults to "./..//..//output") The source path of the image tiles to be stitched. Defaults to "./..//..//output")
- `entities string` - `entities string`
@ -53,7 +57,7 @@ To output the 100x100 area that is centered at the origin use:
./stitch -divide 1 -xmin -50 -xmax 50 -ymin -50 -ymax 50 ./stitch -divide 1 -xmin -50 -xmax 50 -ymin -50 -ymax 50
``` ```
To enter the parameters inside of the program: To start the program interactively:
``` Shell Session ``` Shell Session
./stitch ./stitch

View File

@ -13,13 +13,14 @@ import (
// BlendMethodMedian takes the given tiles and median blends them into destImage. // BlendMethodMedian takes the given tiles and median blends them into destImage.
type BlendMethodMedian struct { type BlendMethodMedian struct {
LimitToNew int // If larger than 0, limits median blending to the `LimitToNew` newest tiles by file modification time. BlendTileLimit int // If larger than 0, limits median blending to the n newest tiles by file modification time.
} }
// Draw implements the StitchedImageBlendMethod interface.
func (b BlendMethodMedian) Draw(tiles []*ImageTile, destImage *image.RGBA) { func (b BlendMethodMedian) Draw(tiles []*ImageTile, destImage *image.RGBA) {
bounds := destImage.Bounds() bounds := destImage.Bounds()
if b.LimitToNew > 0 { if b.BlendTileLimit > 0 {
// Sort tiles by date. // Sort tiles by date.
sort.Slice(tiles, func(i, j int) bool { return tiles[i].modTime.After(tiles[j].modTime) }) sort.Slice(tiles, func(i, j int) bool { return tiles[i].modTime.After(tiles[j].modTime) })
} }
@ -32,7 +33,7 @@ func (b BlendMethodMedian) Draw(tiles []*ImageTile, destImage *image.RGBA) {
} }
// Create arrays to be reused every pixel. // 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)) rListEmpty, gListEmpty, bListEmpty := make([]uint8, 0, len(tiles)), make([]uint8, 0, len(tiles)), make([]uint8, 0, len(tiles))
for iy := bounds.Min.Y; iy < bounds.Max.Y; iy++ { for iy := bounds.Min.Y; iy < bounds.Max.Y; iy++ {
for ix := bounds.Min.X; ix < bounds.Max.X; ix++ { for ix := bounds.Min.X; ix < bounds.Max.X; ix++ {
@ -45,43 +46,39 @@ func (b BlendMethodMedian) Draw(tiles []*ImageTile, destImage *image.RGBA) {
if img != nil { if img != nil {
if point.In(img.Bounds()) { if point.In(img.Bounds()) {
col := img.RGBAAt(point.X, point.Y) 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)) rList, gList, bList = append(rList, col.R), append(gList, col.G), append(bList, col.B)
count++ count++
// Limit number of tiles to median blend. // Limit number of tiles to median blend.
// Will be ignored if LimitToNew is 0. // Will be ignored if the blend tile limit is 0.
if count == b.LimitToNew { if count == b.BlendTileLimit {
break break
} }
} }
} }
} }
// If there were no images to get data from, ignore the pixel. switch count {
if count == 0 { case 0: // If there were no images to get data from, ignore the pixel.
continue continue
}
// Sort colors. Not needed if there is only one color. case 1: // Only a single tile for this pixel.
if count > 1 { r, g, b := uint8(rList[0]), uint8(gList[0]), uint8(bList[0])
sort.Ints(rList) destImage.SetRGBA(ix, iy, color.RGBA{r, g, b, 255})
sort.Ints(gList)
sort.Ints(bList)
}
// Take the middle element of each color. default: // Multiple overlapping tiles, median blend them.
var r, g, b uint8 var r, g, b uint8
switch count % 2 { switch count % 2 {
case 0: // Even. case 0: // Even.
r = uint8((rList[count/2-1] + rList[count/2]) / 2) r = uint8((int(QuickSelectUInt8(rList, count/2-1)) + int(QuickSelectUInt8(rList, count/2))) / 2)
g = uint8((gList[count/2-1] + gList[count/2]) / 2) g = uint8((int(QuickSelectUInt8(gList, count/2-1)) + int(QuickSelectUInt8(gList, count/2))) / 2)
b = uint8((bList[count/2-1] + bList[count/2]) / 2) b = uint8((int(QuickSelectUInt8(bList, count/2-1)) + int(QuickSelectUInt8(bList, count/2))) / 2)
default: // Odd. default: // Odd.
r = uint8(rList[(count-1)/2]) r = QuickSelectUInt8(rList, count/2)
g = uint8(gList[(count-1)/2]) g = QuickSelectUInt8(gList, count/2)
b = uint8(bList[(count-1)/2]) b = QuickSelectUInt8(bList, count/2)
}
destImage.SetRGBA(ix, iy, color.RGBA{r, g, b, 255})
} }
destImage.SetRGBA(ix, iy, color.RGBA{r, g, b, 255})
} }
} }
} }

View File

@ -8,103 +8,12 @@ package main
import ( import (
"encoding/json" "encoding/json"
"image" "image"
"image/color"
"os" "os"
"github.com/tdewolff/canvas" "github.com/tdewolff/canvas"
"github.com/tdewolff/canvas/renderers/rasterizer" "github.com/tdewolff/canvas/renderers/rasterizer"
) )
//var entityDisplayFontFamily = canvas.NewFontFamily("times")
//var entityDisplayFontFace *canvas.FontFace
var entityDisplayAreaDamageStyle = canvas.Style{
FillColor: color.RGBA{100, 0, 0, 100},
StrokeColor: canvas.Transparent,
StrokeWidth: 1.0,
StrokeCapper: canvas.ButtCap,
StrokeJoiner: canvas.MiterJoin,
DashOffset: 0.0,
Dashes: []float64{},
FillRule: canvas.NonZero,
}
var entityDisplayMaterialAreaCheckerStyle = canvas.Style{
FillColor: color.RGBA{0, 0, 127, 127},
StrokeColor: canvas.Transparent,
StrokeWidth: 1.0,
StrokeCapper: canvas.ButtCap,
StrokeJoiner: canvas.MiterJoin,
DashOffset: 0.0,
Dashes: []float64{},
FillRule: canvas.NonZero,
}
var entityDisplayTeleportStyle = canvas.Style{
FillColor: color.RGBA{0, 127, 0, 127},
StrokeColor: canvas.Transparent,
StrokeWidth: 1.0,
StrokeCapper: canvas.ButtCap,
StrokeJoiner: canvas.MiterJoin,
DashOffset: 0.0,
Dashes: []float64{},
FillRule: canvas.NonZero,
}
var entityDisplayHitBoxStyle = canvas.Style{
FillColor: color.RGBA{64, 64, 0, 64},
StrokeColor: color.RGBA{0, 0, 0, 64},
StrokeWidth: 1.0,
StrokeCapper: canvas.ButtCap,
StrokeJoiner: canvas.MiterJoin,
DashOffset: 0.0,
Dashes: []float64{},
FillRule: canvas.NonZero,
}
var entityDisplayCollisionTriggerStyle = canvas.Style{
FillColor: color.RGBA{0, 64, 64, 64},
StrokeColor: color.RGBA{0, 0, 0, 64},
StrokeWidth: 1.0,
StrokeCapper: canvas.ButtCap,
StrokeJoiner: canvas.MiterJoin,
DashOffset: 0.0,
Dashes: []float64{},
FillRule: canvas.NonZero,
}
func init() {
//fontName := "NimbusRoman-Regular"
//if err := entityDisplayFontFamily.LoadLocalFont(fontName, canvas.FontRegular); err != nil {
// log.Printf("Couldn't load font %q: %v", fontName, err)
//}
//entityDisplayFontFace = entityDisplayFontFamily.Face(48.0, canvas.White, canvas.FontRegular, canvas.FontNormal)
}
type Entity struct {
Filename string `json:"filename"`
Transform EntityTransform `json:"transform"`
Children []Entity `json:"children"`
Components []Component `json:"components"`
Name string `json:"name"`
Tags []string `json:"tags"`
}
type EntityTransform struct {
X float32 `json:"x"`
Y float32 `json:"y"`
ScaleX float32 `json:"scaleX"`
ScaleY float32 `json:"scaleY"`
Rotation float32 `json:"rotation"`
}
type Component struct {
TypeName string `json:"typeName"`
Members map[string]any `json:"members"`
}
type Entities []Entity type Entities []Entity
func LoadEntities(path string) (Entities, error) { func LoadEntities(path string) (Entities, error) {
@ -123,124 +32,7 @@ func LoadEntities(path string) (Entities, error) {
return result, nil return result, nil
} }
func (e Entity) Draw(c *canvas.Context) { // Draw implements the StitchedImageOverlay interface.
x, y := float64(e.Transform.X), float64(e.Transform.Y)
for _, component := range e.Components {
switch component.TypeName {
case "AreaDamageComponent": // Area damage like in cursed rock.
var aabbMinX, aabbMinY, aabbMaxX, aabbMaxY float64
if member, ok := component.Members["aabb_min"]; ok {
if aabbMin, ok := member.([]any); ok && len(aabbMin) == 2 {
aabbMinX, _ = aabbMin[0].(float64)
aabbMinY, _ = aabbMin[1].(float64)
}
}
if member, ok := component.Members["aabb_max"]; ok {
if aabbMax, ok := member.([]any); ok && len(aabbMax) == 2 {
aabbMaxX, _ = aabbMax[0].(float64)
aabbMaxY, _ = aabbMax[1].(float64)
}
}
if aabbMinX < aabbMaxX && aabbMinY < aabbMaxY {
c.Style = entityDisplayAreaDamageStyle
c.DrawPath(x+aabbMinX, y+aabbMinY, canvas.Rectangle(aabbMaxX-aabbMinX, aabbMaxY-aabbMinY))
}
if member, ok := component.Members["circle_radius"]; ok {
if radius, ok := member.(float64); ok && radius > 0 {
// Theoretically we need to clip the damage area to the intersection of the AABB and the circle, but meh.
// TODO: Clip the area to the intersection of the box and the circle
cx, cy := (aabbMinX+aabbMaxX)/2, (aabbMinY+aabbMaxY)/2
c.Style = entityDisplayAreaDamageStyle
c.DrawPath(x+cx, y+cy, canvas.Circle(radius))
}
}
case "MaterialAreaCheckerComponent": // Checks for materials in the given AABB.
var aabbMinX, aabbMinY, aabbMaxX, aabbMaxY float64
if member, ok := component.Members["area_aabb"]; ok {
if aabb, ok := member.([]any); ok && len(aabb) == 4 {
aabbMinX, _ = aabb[0].(float64)
aabbMinY, _ = aabb[1].(float64)
aabbMaxX, _ = aabb[2].(float64)
aabbMaxY, _ = aabb[3].(float64)
}
}
if aabbMinX < aabbMaxX && aabbMinY < aabbMaxY {
c.Style = entityDisplayMaterialAreaCheckerStyle
c.DrawPath(x+aabbMinX, y+aabbMinY, canvas.Rectangle(aabbMaxX-aabbMinX, aabbMaxY-aabbMinY))
}
case "TeleportComponent":
var aabbMinX, aabbMinY, aabbMaxX, aabbMaxY float64
if member, ok := component.Members["source_location_camera_aabb"]; ok {
if aabb, ok := member.([]any); ok && len(aabb) == 4 {
aabbMinX, _ = aabb[0].(float64)
aabbMinY, _ = aabb[1].(float64)
aabbMaxX, _ = aabb[2].(float64)
aabbMaxY, _ = aabb[3].(float64)
}
}
if aabbMinX < aabbMaxX && aabbMinY < aabbMaxY {
c.Style = entityDisplayTeleportStyle
c.DrawPath(x+aabbMinX, y+aabbMinY, canvas.Rectangle(aabbMaxX-aabbMinX, aabbMaxY-aabbMinY))
}
case "HitboxComponent": // General hit box component.
var aabbMinX, aabbMinY, aabbMaxX, aabbMaxY float64
if member, ok := component.Members["aabb_min_x"]; ok {
aabbMinX, _ = member.(float64)
}
if member, ok := component.Members["aabb_min_y"]; ok {
aabbMinY, _ = member.(float64)
}
if member, ok := component.Members["aabb_max_x"]; ok {
aabbMaxX, _ = member.(float64)
}
if member, ok := component.Members["aabb_max_y"]; ok {
aabbMaxY, _ = member.(float64)
}
if aabbMinX < aabbMaxX && aabbMinY < aabbMaxY {
c.Style = entityDisplayHitBoxStyle
c.DrawPath(x+aabbMinX, y+aabbMinY, canvas.Rectangle(aabbMaxX-aabbMinX, aabbMaxY-aabbMinY))
}
case "CollisionTriggerComponent": // Checks if another entity is inside the given radius and box with the given width and height.
var width, height float64
path := &canvas.Path{}
if member, ok := component.Members["width"]; ok {
width, _ = member.(float64)
}
if member, ok := component.Members["height"]; ok {
height, _ = member.(float64)
}
if width > 0 && height > 0 {
path = canvas.Rectangle(width, height).Translate(-width/2, -height/2)
}
// Theoretically we need to clip the area to the intersection of the box and the circle, but meh.
// TODO: Clip the area to the intersection of the box and the circle
//if member, ok := component.Members["radius"]; ok {
// if radius, ok := member.(float64); ok && radius > 0 {
// path = path.Append(canvas.Circle(radius))
// path.And()
// }
//}
if !path.Empty() {
c.Style = entityDisplayCollisionTriggerStyle
c.DrawPath(x, y, path)
}
}
}
c.SetFillColor(color.RGBA{255, 255, 255, 128})
c.SetStrokeColor(color.RGBA{255, 0, 0, 255})
c.DrawPath(x, y, canvas.Circle(3))
//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) { func (e Entities) Draw(destImage *image.RGBA) {
destRect := destImage.Bounds() destRect := destImage.Bounds()

220
bin/stitch/entity.go Normal file
View File

@ -0,0 +1,220 @@
// Copyright (c) 2022 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"image/color"
"github.com/tdewolff/canvas"
)
//var entityDisplayFontFamily = canvas.NewFontFamily("times")
//var entityDisplayFontFace *canvas.FontFace
var entityDisplayAreaDamageStyle = canvas.Style{
FillColor: color.RGBA{100, 0, 0, 100},
StrokeColor: canvas.Transparent,
StrokeWidth: 1.0,
StrokeCapper: canvas.ButtCap,
StrokeJoiner: canvas.MiterJoin,
DashOffset: 0.0,
Dashes: []float64{},
FillRule: canvas.NonZero,
}
var entityDisplayMaterialAreaCheckerStyle = canvas.Style{
FillColor: color.RGBA{0, 0, 127, 127},
StrokeColor: canvas.Transparent,
StrokeWidth: 1.0,
StrokeCapper: canvas.ButtCap,
StrokeJoiner: canvas.MiterJoin,
DashOffset: 0.0,
Dashes: []float64{},
FillRule: canvas.NonZero,
}
var entityDisplayTeleportStyle = canvas.Style{
FillColor: color.RGBA{0, 127, 0, 127},
StrokeColor: canvas.Transparent,
StrokeWidth: 1.0,
StrokeCapper: canvas.ButtCap,
StrokeJoiner: canvas.MiterJoin,
DashOffset: 0.0,
Dashes: []float64{},
FillRule: canvas.NonZero,
}
var entityDisplayHitBoxStyle = canvas.Style{
FillColor: color.RGBA{64, 64, 0, 64},
StrokeColor: color.RGBA{0, 0, 0, 64},
StrokeWidth: 1.0,
StrokeCapper: canvas.ButtCap,
StrokeJoiner: canvas.MiterJoin,
DashOffset: 0.0,
Dashes: []float64{},
FillRule: canvas.NonZero,
}
var entityDisplayCollisionTriggerStyle = canvas.Style{
FillColor: color.RGBA{0, 64, 64, 64},
StrokeColor: color.RGBA{0, 0, 0, 64},
StrokeWidth: 1.0,
StrokeCapper: canvas.ButtCap,
StrokeJoiner: canvas.MiterJoin,
DashOffset: 0.0,
Dashes: []float64{},
FillRule: canvas.NonZero,
}
func init() {
//fontName := "NimbusRoman-Regular"
//if err := entityDisplayFontFamily.LoadLocalFont(fontName, canvas.FontRegular); err != nil {
// log.Printf("Couldn't load font %q: %v", fontName, err)
//}
//entityDisplayFontFace = entityDisplayFontFamily.Face(48.0, canvas.White, canvas.FontRegular, canvas.FontNormal)
}
type Entity struct {
Filename string `json:"filename"`
Transform EntityTransform `json:"transform"`
Children []Entity `json:"children"`
Components []Component `json:"components"`
Name string `json:"name"`
Tags []string `json:"tags"`
}
type EntityTransform struct {
X float32 `json:"x"`
Y float32 `json:"y"`
ScaleX float32 `json:"scaleX"`
ScaleY float32 `json:"scaleY"`
Rotation float32 `json:"rotation"`
}
type Component struct {
TypeName string `json:"typeName"`
Members map[string]any `json:"members"`
}
func (e Entity) Draw(c *canvas.Context) {
x, y := float64(e.Transform.X), float64(e.Transform.Y)
for _, component := range e.Components {
switch component.TypeName {
case "AreaDamageComponent": // Area damage like in cursed rock.
var aabbMinX, aabbMinY, aabbMaxX, aabbMaxY float64
if member, ok := component.Members["aabb_min"]; ok {
if aabbMin, ok := member.([]any); ok && len(aabbMin) == 2 {
aabbMinX, _ = aabbMin[0].(float64)
aabbMinY, _ = aabbMin[1].(float64)
}
}
if member, ok := component.Members["aabb_max"]; ok {
if aabbMax, ok := member.([]any); ok && len(aabbMax) == 2 {
aabbMaxX, _ = aabbMax[0].(float64)
aabbMaxY, _ = aabbMax[1].(float64)
}
}
if aabbMinX < aabbMaxX && aabbMinY < aabbMaxY {
c.Style = entityDisplayAreaDamageStyle
c.DrawPath(x+aabbMinX, y+aabbMinY, canvas.Rectangle(aabbMaxX-aabbMinX, aabbMaxY-aabbMinY))
}
if member, ok := component.Members["circle_radius"]; ok {
if radius, ok := member.(float64); ok && radius > 0 {
// Theoretically we need to clip the damage area to the intersection of the AABB and the circle, but meh.
// TODO: Clip the area to the intersection of the box and the circle
cx, cy := (aabbMinX+aabbMaxX)/2, (aabbMinY+aabbMaxY)/2
c.Style = entityDisplayAreaDamageStyle
c.DrawPath(x+cx, y+cy, canvas.Circle(radius))
}
}
case "MaterialAreaCheckerComponent": // Checks for materials in the given AABB.
var aabbMinX, aabbMinY, aabbMaxX, aabbMaxY float64
if member, ok := component.Members["area_aabb"]; ok {
if aabb, ok := member.([]any); ok && len(aabb) == 4 {
aabbMinX, _ = aabb[0].(float64)
aabbMinY, _ = aabb[1].(float64)
aabbMaxX, _ = aabb[2].(float64)
aabbMaxY, _ = aabb[3].(float64)
}
}
if aabbMinX < aabbMaxX && aabbMinY < aabbMaxY {
c.Style = entityDisplayMaterialAreaCheckerStyle
c.DrawPath(x+aabbMinX, y+aabbMinY, canvas.Rectangle(aabbMaxX-aabbMinX, aabbMaxY-aabbMinY))
}
case "TeleportComponent":
var aabbMinX, aabbMinY, aabbMaxX, aabbMaxY float64
if member, ok := component.Members["source_location_camera_aabb"]; ok {
if aabb, ok := member.([]any); ok && len(aabb) == 4 {
aabbMinX, _ = aabb[0].(float64)
aabbMinY, _ = aabb[1].(float64)
aabbMaxX, _ = aabb[2].(float64)
aabbMaxY, _ = aabb[3].(float64)
}
}
if aabbMinX < aabbMaxX && aabbMinY < aabbMaxY {
c.Style = entityDisplayTeleportStyle
c.DrawPath(x+aabbMinX, y+aabbMinY, canvas.Rectangle(aabbMaxX-aabbMinX, aabbMaxY-aabbMinY))
}
case "HitboxComponent": // General hit box component.
var aabbMinX, aabbMinY, aabbMaxX, aabbMaxY float64
if member, ok := component.Members["aabb_min_x"]; ok {
aabbMinX, _ = member.(float64)
}
if member, ok := component.Members["aabb_min_y"]; ok {
aabbMinY, _ = member.(float64)
}
if member, ok := component.Members["aabb_max_x"]; ok {
aabbMaxX, _ = member.(float64)
}
if member, ok := component.Members["aabb_max_y"]; ok {
aabbMaxY, _ = member.(float64)
}
if aabbMinX < aabbMaxX && aabbMinY < aabbMaxY {
c.Style = entityDisplayHitBoxStyle
c.DrawPath(x+aabbMinX, y+aabbMinY, canvas.Rectangle(aabbMaxX-aabbMinX, aabbMaxY-aabbMinY))
}
case "CollisionTriggerComponent": // Checks if another entity is inside the given radius and box with the given width and height.
var width, height float64
path := &canvas.Path{}
if member, ok := component.Members["width"]; ok {
width, _ = member.(float64)
}
if member, ok := component.Members["height"]; ok {
height, _ = member.(float64)
}
if width > 0 && height > 0 {
path = canvas.Rectangle(width, height).Translate(-width/2, -height/2)
}
// Theoretically we need to clip the area to the intersection of the box and the circle, but meh.
// TODO: Clip the area to the intersection of the box and the circle
//if member, ok := component.Members["radius"]; ok {
// if radius, ok := member.(float64); ok && radius > 0 {
// path = path.Append(canvas.Circle(radius))
// path.And()
// }
//}
if !path.Empty() {
c.Style = entityDisplayCollisionTriggerStyle
c.DrawPath(x, y, path)
}
}
}
c.SetFillColor(color.RGBA{255, 255, 255, 128})
c.SetStrokeColor(color.RGBA{255, 0, 0, 255})
c.DrawPath(x, y, canvas.Circle(3))
//text := canvas.NewTextLine(entityDisplayFontFace, fmt.Sprintf("%s\n%s", e.Name, e.Filename), canvas.Left)
//c.DrawText(x, y, text)
}

View File

@ -28,9 +28,11 @@ type ImageTile struct {
scaleDivider int // Downscales the coordinates and images on the fly. 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. image image.Image // Either a rectangle or an RGBA image. The bounds of this image are determined by the filename.
imageMutex *sync.RWMutex // imageMutex *sync.RWMutex //
imageUsedFlag bool // Flag signalling, that the image was used recently.
invalidationChan chan struct{} // Used to send invalidation requests to the tile's goroutine.
timeoutChan chan struct{} // Used to determine whether the tile is still being accessed or not.
} }
// NewImageTile returns an image tile object that represents the image at the given path. // NewImageTile returns an image tile object that represents the image at the given path.
@ -54,7 +56,7 @@ func NewImageTile(path string, scaleDivider int) (ImageTile, error) {
return ImageTile{}, fmt.Errorf("error parsing %q to integer: %w", result[2], err) return ImageTile{}, fmt.Errorf("error parsing %q to integer: %w", result[2], err)
} }
width, height, err := getImageFileDimension(path) width, height, err := GetImageFileDimension(path)
if err != nil { if err != nil {
return ImageTile{}, err return ImageTile{}, err
} }
@ -66,11 +68,13 @@ func NewImageTile(path string, scaleDivider int) (ImageTile, error) {
} }
return ImageTile{ return ImageTile{
fileName: path, fileName: path,
modTime: modTime, modTime: modTime,
scaleDivider: scaleDivider, scaleDivider: scaleDivider,
image: image.Rect(x/scaleDivider, y/scaleDivider, (x+width)/scaleDivider, (y+height)/scaleDivider), image: image.Rect(x/scaleDivider, y/scaleDivider, (x+width)/scaleDivider, (y+height)/scaleDivider),
imageMutex: &sync.RWMutex{}, imageMutex: &sync.RWMutex{},
invalidationChan: make(chan struct{}, 1),
timeoutChan: make(chan struct{}, 1),
}, nil }, nil
} }
@ -80,7 +84,11 @@ func NewImageTile(path string, scaleDivider int) (ImageTile, error) {
func (it *ImageTile) GetImage() *image.RGBA { func (it *ImageTile) GetImage() *image.RGBA {
it.imageMutex.RLock() it.imageMutex.RLock()
it.imageUsedFlag = true // Race condition may happen on this flag, but doesn't matter here. // Clear the timeout chan to signal that the image is still being used.
select {
case <-it.timeoutChan:
default:
}
// Check if the image is already loaded. // Check if the image is already loaded.
if img, ok := it.image.(*image.RGBA); ok { if img, ok := it.image.(*image.RGBA); ok {
@ -128,13 +136,43 @@ func (it *ImageTile) GetImage() *image.RGBA {
it.image = imgRGBA it.image = imgRGBA
// Free the image after some time. // Clear any old invalidation request.
select {
case <-it.invalidationChan:
default:
}
// Fill timeout channel with one element.
// This is needed, as the ticker doesn't send a tick on initialization.
select {
case it.timeoutChan <- struct{}{}:
default:
}
// Free the image after some time or if requested externally.
go func() { go func() {
for it.imageUsedFlag { // Set up watchdog that checks if the image is being used.
it.imageUsedFlag = false ticker := time.NewTicker(5000 * time.Millisecond)
time.Sleep(1000 * time.Millisecond) defer ticker.Stop()
loop:
for {
select {
case <-ticker.C:
// Try to send to the timeout channel.
select {
case it.timeoutChan <- struct{}{}:
default:
// Timeout channel is full because the tile image wasn't queried recently.
break loop
}
case <-it.invalidationChan:
// An invalidation was requested externally.
break loop
}
} }
// Free image and other stuff.
it.imageMutex.Lock() it.imageMutex.Lock()
defer it.imageMutex.Unlock() defer it.imageMutex.Unlock()
it.image = it.image.Bounds() it.image = it.image.Bounds()
@ -143,6 +181,18 @@ func (it *ImageTile) GetImage() *image.RGBA {
return imgRGBA return imgRGBA
} }
// Clears the cached image.
func (it *ImageTile) Invalidate() {
it.imageMutex.RLock()
defer it.imageMutex.RUnlock()
// Try to send invalidation request.
select {
case it.invalidationChan <- struct{}{}:
default:
}
}
// The scaled image boundaries. // The scaled image boundaries.
// This matches exactly to what GetImage() returns. // This matches exactly to what GetImage() returns.
func (it *ImageTile) Bounds() image.Rectangle { func (it *ImageTile) Bounds() image.Rectangle {

View File

@ -10,13 +10,15 @@ import (
"path/filepath" "path/filepath"
) )
type ImageTiles []ImageTile
// LoadImageTiles "loads" all images in the directory at the given path. // LoadImageTiles "loads" all images in the directory at the given path.
func LoadImageTiles(path string, scaleDivider int) ([]ImageTile, error) { func LoadImageTiles(path string, scaleDivider int) (ImageTiles, error) {
if scaleDivider < 1 { if scaleDivider < 1 {
return nil, fmt.Errorf("invalid scale of %v", scaleDivider) return nil, fmt.Errorf("invalid scale of %v", scaleDivider)
} }
var imageTiles []ImageTile var imageTiles ImageTiles
files, err := filepath.Glob(filepath.Join(path, "*.png")) files, err := filepath.Glob(filepath.Join(path, "*.png"))
if err != nil { if err != nil {
@ -34,3 +36,12 @@ func LoadImageTiles(path string, scaleDivider int) ([]ImageTile, error) {
return imageTiles, nil return imageTiles, nil
} }
// InvalidateAboveY invalidates all cached images that have no pixel at the given y coordinate or below.
func (it ImageTiles) InvalidateAboveY(y int) {
for _, tile := range it {
if tile.Bounds().Max.Y <= y {
tile.Invalidate()
}
}
}

View File

@ -25,13 +25,14 @@ var flagEntitiesInputPath = flag.String("entities", filepath.Join(".", "..", "..
var flagPlayerPathInputPath = flag.String("player-path", filepath.Join(".", "..", "..", "output", "player-path.json"), "The path to the player-path.json file.") var flagPlayerPathInputPath = flag.String("player-path", filepath.Join(".", "..", "..", "output", "player-path.json"), "The path to the player-path.json file.")
var flagOutputPath = flag.String("output", filepath.Join(".", "output.png"), "The path and filename of the resulting stitched image.") var flagOutputPath = flag.String("output", filepath.Join(".", "output.png"), "The path and filename of the resulting stitched image.")
var flagScaleDivider = flag.Int("divide", 1, "A downscaling factor. 2 will produce an image with half the side lengths.") var flagScaleDivider = flag.Int("divide", 1, "A downscaling factor. 2 will produce an image with half the side lengths.")
var flagBlendTileLimit = flag.Int("tile-limit", 9, "Limits median blending to the n newest tiles by file modification time. If set to 0, all available tiles will be median blended.")
var flagXMin = flag.Int("xmin", 0, "Left bound of the output rectangle. This coordinate is included in the output.") var flagXMin = flag.Int("xmin", 0, "Left bound of the output rectangle. This coordinate is included in the output.")
var flagYMin = flag.Int("ymin", 0, "Upper bound of the output rectangle. This coordinate is included in the output.") var flagYMin = flag.Int("ymin", 0, "Upper bound of the output rectangle. This coordinate is included in the output.")
var flagXMax = flag.Int("xmax", 0, "Right bound of the output rectangle. This coordinate is not included in the output.") var flagXMax = flag.Int("xmax", 0, "Right bound of the output rectangle. This coordinate is not included in the output.")
var flagYMax = flag.Int("ymax", 0, "Lower bound of the output rectangle. This coordinate is not included in the output.") var flagYMax = flag.Int("ymax", 0, "Lower bound of the output rectangle. This coordinate is not included in the output.")
func main() { func main() {
log.Printf("Noita MapCapture stitching tool v%s", version) log.Printf("Noita MapCapture stitching tool v%s.", version)
flag.Parse() flag.Parse()
@ -59,11 +60,38 @@ func main() {
result, err := prompt.Run() result, err := prompt.Run()
if err != nil { if err != nil {
log.Panicf("Error while getting user input: %v", err) log.Panicf("Error while getting user input: %v.", err)
} }
fmt.Sscanf(result, "%d", flagScaleDivider) fmt.Sscanf(result, "%d", flagScaleDivider)
} }
// Query the user, if there were no cmd arguments given.
if flag.NFlag() == 0 {
prompt := promptui.Prompt{
Label: "Enter blend tile limit:",
Default: fmt.Sprint(*flagBlendTileLimit),
AllowEdit: true,
Validate: func(s string) error {
var num int
_, err := fmt.Sscanf(s, "%d", &num)
if err != nil {
return err
}
if int(num) < 0 {
return fmt.Errorf("number must be at least 0")
}
return nil
},
}
result, err := prompt.Run()
if err != nil {
log.Panicf("Error while getting user input: %v.", err)
}
fmt.Sscanf(result, "%d", flagBlendTileLimit)
}
// Query the user, if there were no cmd arguments given. // Query the user, if there were no cmd arguments given.
if flag.NFlag() == 0 { if flag.NFlag() == 0 {
prompt := promptui.Prompt{ prompt := promptui.Prompt{
@ -74,7 +102,7 @@ func main() {
result, err := prompt.Run() result, err := prompt.Run()
if err != nil { if err != nil {
log.Panicf("Error while getting user input: %v", err) log.Panicf("Error while getting user input: %v.", err)
} }
*flagInputPath = result *flagInputPath = result
} }
@ -89,7 +117,7 @@ func main() {
result, err := prompt.Run() result, err := prompt.Run()
if err != nil { if err != nil {
log.Panicf("Error while getting user input: %v", err) log.Panicf("Error while getting user input: %v.", err)
} }
*flagEntitiesInputPath = result *flagEntitiesInputPath = result
} }
@ -97,7 +125,7 @@ func main() {
// Load entities if requested. // Load entities if requested.
entities, err := LoadEntities(*flagEntitiesInputPath) entities, err := LoadEntities(*flagEntitiesInputPath)
if err != nil { if err != nil {
log.Printf("Failed to load entities: %v", err) log.Printf("Failed to load entities: %v.", err)
} }
if len(entities) > 0 { if len(entities) > 0 {
log.Printf("Got %v entities.", len(entities)) log.Printf("Got %v entities.", len(entities))
@ -114,7 +142,7 @@ func main() {
result, err := prompt.Run() result, err := prompt.Run()
if err != nil { if err != nil {
log.Panicf("Error while getting user input: %v", err) log.Panicf("Error while getting user input: %v.", err)
} }
*flagPlayerPathInputPath = result *flagPlayerPathInputPath = result
} }
@ -122,22 +150,22 @@ func main() {
// Load player path if requested. // Load player path if requested.
playerPath, err := LoadPlayerPath(*flagPlayerPathInputPath) playerPath, err := LoadPlayerPath(*flagPlayerPathInputPath)
if err != nil { if err != nil {
log.Printf("Failed to load player path: %v", err) log.Printf("Failed to load player path: %v.", err)
} }
if len(playerPath) > 0 { if len(playerPath) > 0 {
log.Printf("Got %v player path entries.", len(playerPath)) log.Printf("Got %v player path entries.", len(playerPath))
overlays = append(overlays, playerPath) // Add player path to overlay drawing list. overlays = append(overlays, playerPath) // Add player path to overlay drawing list.
} }
log.Printf("Starting to read tile information at \"%v\"", *flagInputPath) log.Printf("Starting to read tile information at %q.", *flagInputPath)
tiles, err := LoadImageTiles(*flagInputPath, *flagScaleDivider) tiles, err := LoadImageTiles(*flagInputPath, *flagScaleDivider)
if err != nil { if err != nil {
log.Panic(err) log.Panic(err)
} }
if len(tiles) == 0 { if len(tiles) == 0 {
log.Panicf("Got no tiles inside of %v", *flagInputPath) log.Panicf("Got no image tiles from %q.", *flagInputPath)
} }
log.Printf("Got %v tiles", len(tiles)) log.Printf("Got %v tiles.", len(tiles))
totalBounds := image.Rectangle{} totalBounds := image.Rectangle{}
for i, tile := range tiles { for i, tile := range tiles {
@ -147,17 +175,7 @@ func main() {
totalBounds = totalBounds.Union(tile.Bounds()) totalBounds = totalBounds.Union(tile.Bounds())
} }
} }
log.Printf("Total size of the possible output space is %v", totalBounds) log.Printf("Total size of the possible output space is %v.", totalBounds)
/*profFile, err := os.Create("cpu.prof")
if err != nil {
log.Panicf("could not create CPU profile: %v", err)
}
defer profFile.Close()
if err := pprof.StartCPUProfile(profFile); err != nil {
log.Panicf("could not start CPU profile: %v", err)
}
defer pprof.StopCPUProfile()*/
// If the output rect is empty, use the rectangle that encloses all tiles. // If the output rect is empty, use the rectangle that encloses all tiles.
outputRect := image.Rect(*flagXMin, *flagYMin, *flagXMax, *flagYMax) outputRect := image.Rect(*flagXMin, *flagYMin, *flagXMax, *flagYMax)
@ -190,7 +208,7 @@ func main() {
result, err := prompt.Run() result, err := prompt.Run()
if err != nil { if err != nil {
log.Panicf("Error while getting user input: %v", err) log.Panicf("Error while getting user input: %v.", err)
} }
var xMin, yMin, xMax, yMax int var xMin, yMin, xMax, yMax int
fmt.Sscanf(result, "%d,%d;%d,%d", &xMin, &yMin, &xMax, &yMax) fmt.Sscanf(result, "%d,%d;%d,%d", &xMin, &yMin, &xMax, &yMax)
@ -207,32 +225,34 @@ func main() {
result, err := prompt.Run() result, err := prompt.Run()
if err != nil { if err != nil {
log.Panicf("Error while getting user input: %v", err) log.Panicf("Error while getting user input: %v.", err)
} }
*flagOutputPath = result *flagOutputPath = result
} }
startTime := time.Now()
bar := pb.Full.New(0) bar := pb.Full.New(0)
var wg sync.WaitGroup var wg sync.WaitGroup
done := make(chan struct{}) done := make(chan struct{})
blendMethod := BlendMethodMedian{ blendMethod := BlendMethodMedian{
LimitToNew: 1, // Limit median blending to the n newest tiles by file modification time. BlendTileLimit: *flagBlendTileLimit, // Limit median blending to the n newest tiles by file modification time.
} }
outputImage, err := NewStitchedImage(tiles, outputRect, blendMethod, 512, overlays) outputImage, err := NewStitchedImage(tiles, outputRect, blendMethod, 64, overlays)
if err != nil { if err != nil {
log.Panicf("NewStitchedImage() failed: %v", err) log.Panicf("NewStitchedImage() failed: %v.", err)
} }
_, max := outputImage.Progress() _, max := outputImage.Progress()
bar.SetTotal(int64(max)).Start().SetRefreshRate(1 * time.Second) bar.SetTotal(int64(max)).Start().SetRefreshRate(250 * time.Millisecond)
// Query progress and draw progress bar. // Query progress and draw progress bar.
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
ticker := time.NewTicker(1 * time.Second) ticker := time.NewTicker(250 * time.Millisecond)
for { for {
select { select {
case <-done: case <-done:
@ -247,13 +267,17 @@ func main() {
} }
}() }()
log.Printf("Creating output file \"%v\"", *flagOutputPath) log.Printf("Creating output file %q.", *flagOutputPath)
f, err := os.Create(*flagOutputPath) f, err := os.Create(*flagOutputPath)
if err != nil { if err != nil {
log.Panic(err) log.Panic(err)
} }
if err := png.Encode(f, outputImage); err != nil { encoder := png.Encoder{
CompressionLevel: png.DefaultCompression,
}
if err := encoder.Encode(f, outputImage); err != nil {
f.Close() f.Close()
log.Panic(err) log.Panic(err)
} }
@ -264,6 +288,8 @@ func main() {
if err := f.Close(); err != nil { if err := f.Close(); err != nil {
log.Panic(err) log.Panic(err)
} }
log.Printf("Created output file \"%v\"", *flagOutputPath) log.Printf("Created output file %q in %v.", *flagOutputPath, time.Since(startTime))
//fmt.Println("Press the enter key to terminate the console screen!")
//fmt.Scanln()
} }

View File

@ -53,6 +53,7 @@ func LoadPlayerPath(path string) (PlayerPath, error) {
return result, nil return result, nil
} }
// Draw implements the StitchedImageOverlay interface.
func (p PlayerPath) Draw(destImage *image.RGBA) { func (p PlayerPath) Draw(destImage *image.RGBA) {
destRect := destImage.Bounds() destRect := destImage.Bounds()

21
bin/stitch/profiling.go Normal file
View File

@ -0,0 +1,21 @@
// Copyright (c) 2022 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
_ "net/http/pprof"
)
func init() {
/*port := 1234
go func() {
http.ListenAndServe(fmt.Sprintf(":%d", port), nil)
}()
log.Printf("Profiler web server listening on port %d. Visit http://localhost:%d/debug/pprof", port, port)
log.Printf("To profile the next 10 seconds and view the profile interactively:\n go tool pprof -http :8080 http://localhost:%d/debug/pprof/profile?seconds=10", port)
*/
}

View File

@ -0,0 +1,133 @@
// Copyright (c) 2022 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"image"
"image/color"
"runtime"
"sync"
)
// StitchedImageCache contains part of the actual image data of a stitched image.
// This can be regenerated or invalidated at will.
type StitchedImageCache struct {
sync.Mutex
stitchedImage *StitchedImage // The parent object.
rect image.Rectangle // Position and size of the cached area.
image *image.RGBA // Cached RGBA image. The bounds of this image are determined by the filename.
}
func NewStitchedImageCache(stitchedImage *StitchedImage, rect image.Rectangle) StitchedImageCache {
return StitchedImageCache{
stitchedImage: stitchedImage,
rect: rect,
}
}
// Invalidate clears the cached image.
func (sic *StitchedImageCache) Invalidate() {
sic.Lock()
defer sic.Unlock()
sic.image = nil
}
// Regenerate refills the cache image with valid image data.
// This will block until there is a valid image, and it will *always* return a valid image.
func (sic *StitchedImageCache) Regenerate() *image.RGBA {
sic.Lock()
defer sic.Unlock()
// Check if there is already a cache image.
if sic.image != nil {
return sic.image
}
si := sic.stitchedImage
cacheImage := image.NewRGBA(sic.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(sic.rect) {
tilePtr := &si.tiles[i]
intersectingTiles = append(intersectingTiles, tilePtr)
}
}
// Start worker threads.
workerQueue := make(chan image.Rectangle)
waitGroup := sync.WaitGroup{}
workers := (runtime.NumCPU() + 1) / 2
for i := 0; i < workers; 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 workload bounds.
for _, tile := range intersectingTiles {
if tile.Bounds().Overlaps(workload) {
workloadTiles = append(workloadTiles, tile)
}
}
// Draw blended tiles into cache image.
// Restricted by the workload rectangle.
si.blendMethod.Draw(workloadTiles, cacheImage.SubImage(workload).(*image.RGBA))
}
}()
}
// Divide rect into chunks and push to workers.
for _, chunk := range GridifyRectangle(sic.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.
sic.image = cacheImage
return cacheImage
}
// Returns the pixel color at x and y.
func (sic *StitchedImageCache) RGBAAt(x, y int) color.RGBA {
// Fast path: The image is loaded.
sic.Lock()
if sic.image != nil {
defer sic.Unlock()
return sic.image.RGBAAt(x, y)
}
sic.Unlock()
// Slow path: The image data needs to be generated first.
// This will block until the cache is regenerated.
return sic.Regenerate().RGBAAt(x, y)
}
// Returns the pixel color at x and y.
func (sic *StitchedImageCache) At(x, y int) color.Color {
return sic.RGBAAt(x, y)
}
func (sic *StitchedImageCache) Bounds() image.Rectangle {
return sic.rect
}

View File

@ -9,19 +9,17 @@ import (
"fmt" "fmt"
"image" "image"
"image/color" "image/color"
"runtime"
"sync"
) )
// StitchedImageCacheGridSize defines the worker chunk size when the cache image is regenerated. // 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 = 256
var StitchedImageCacheGridSize = 512
// StitchedImageBlendMethod defines how tiles are blended together. // StitchedImageBlendMethod defines how tiles are blended together.
type StitchedImageBlendMethod interface { type StitchedImageBlendMethod interface {
Draw(tiles []*ImageTile, destImage *image.RGBA) // Draw is called when a new cache image is generated. Draw(tiles []*ImageTile, destImage *image.RGBA) // Draw is called when a new cache image is generated.
} }
// StitchedImageOverlay defines an interface for arbitrary overlays that can be drawn over the stitched image.
type StitchedImageOverlay interface { type StitchedImageOverlay interface {
Draw(*image.RGBA) Draw(*image.RGBA)
} }
@ -29,37 +27,50 @@ type StitchedImageOverlay interface {
// StitchedImage combines several ImageTile objects into a single RGBA image. // StitchedImage combines several ImageTile objects into a single RGBA image.
// The way the images are combined/blended is defined by the blendFunc. // The way the images are combined/blended is defined by the blendFunc.
type StitchedImage struct { type StitchedImage struct {
tiles []ImageTile tiles ImageTiles
bounds image.Rectangle bounds image.Rectangle
blendMethod StitchedImageBlendMethod blendMethod StitchedImageBlendMethod
overlays []StitchedImageOverlay overlays []StitchedImageOverlay
cacheHeight int cacheRowHeight int
cacheImage *image.RGBA cacheRows []StitchedImageCache
cacheRowYOffset int // Defines the pixel offset of the first cache row.
queryCounter int oldCacheRowIndex int
queryCounter int
} }
// NewStitchedImage creates a new image from several single image tiles. // NewStitchedImage creates a new image from several single image tiles.
func NewStitchedImage(tiles []ImageTile, bounds image.Rectangle, blendMethod StitchedImageBlendMethod, cacheHeight int, overlays []StitchedImageOverlay) (*StitchedImage, error) { func NewStitchedImage(tiles ImageTiles, bounds image.Rectangle, blendMethod StitchedImageBlendMethod, cacheRowHeight int, overlays []StitchedImageOverlay) (*StitchedImage, error) {
if bounds.Empty() { if bounds.Empty() {
return nil, fmt.Errorf("given boundaries are empty") return nil, fmt.Errorf("given boundaries are empty")
} }
if blendMethod == nil { if blendMethod == nil {
return nil, fmt.Errorf("no blending method given") return nil, fmt.Errorf("no blending method given")
} }
if cacheHeight <= 0 { if cacheRowHeight <= 0 {
return nil, fmt.Errorf("invalid cache height of %d pixels", cacheHeight) return nil, fmt.Errorf("invalid cache row height of %d pixels", cacheRowHeight)
} }
return &StitchedImage{ stitchedImage := &StitchedImage{
tiles: tiles, tiles: tiles,
bounds: bounds, bounds: bounds,
blendMethod: blendMethod, blendMethod: blendMethod,
overlays: overlays, overlays: overlays,
cacheHeight: cacheHeight, }
cacheImage: &image.RGBA{},
}, nil // Generate cache image rows.
rows := bounds.Dy() / cacheRowHeight
var cacheRows []StitchedImageCache
for i := 0; i < rows; i++ {
rect := image.Rect(bounds.Min.X, bounds.Min.Y+i*cacheRowHeight, bounds.Max.X, bounds.Min.Y+(i+1)*cacheRowHeight)
cacheRows = append(cacheRows, NewStitchedImageCache(stitchedImage, rect.Intersect(bounds)))
}
stitchedImage.cacheRowHeight = cacheRowHeight
stitchedImage.cacheRowYOffset = -bounds.Min.Y
stitchedImage.cacheRows = cacheRows
return stitchedImage, nil
} }
// ColorModel returns the Image's color model. // ColorModel returns the Image's color model.
@ -73,6 +84,10 @@ func (si *StitchedImage) Bounds() image.Rectangle {
return si.bounds return si.bounds
} }
func (si *StitchedImage) At(x, y int) color.Color {
return si.RGBAAt(x, y)
}
// At returns the color of the pixel at (x, y). // 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. // This is optimized to be read line by line (scanning), it will be much slower with random access.
@ -81,25 +96,38 @@ func (si *StitchedImage) Bounds() image.Rectangle {
// //
// At(Bounds().Min.X, Bounds().Min.Y) // returns the top-left pixel of the image. // 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. // At(Bounds().Max.X-1, Bounds().Max.Y-1) // returns the bottom-right pixel.
// func (si *StitchedImage) RGBAAt(x, y int) color.RGBA {
// 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. // Assume that every pixel is only queried once.
si.queryCounter++ si.queryCounter++
// Check if cached image needs to be regenerated. // Determine the cache rowIndex index.
if !p.In(si.cacheImage.Bounds()) { rowIndex := (y + si.cacheRowYOffset) / si.cacheRowHeight
rect := si.Bounds() if rowIndex < 0 || rowIndex >= len(si.cacheRows) {
// TODO: Redo how the cache image rect is generated return color.RGBA{}
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) // Check if we advanced/changed the row index.
// This doesn't happen a lot, so stuff inside this can be a bit more expensive.
if si.oldCacheRowIndex != rowIndex {
// Pre generate the new row asynchronously.
newRowIndex := rowIndex + 1
if newRowIndex >= 0 && newRowIndex < len(si.cacheRows) {
go si.cacheRows[newRowIndex].Regenerate()
}
// Invalidate old cache row.
oldRowIndex := si.oldCacheRowIndex
if oldRowIndex >= 0 && oldRowIndex < len(si.cacheRows) {
si.cacheRows[oldRowIndex].Invalidate()
}
// Invalidate all tiles that are above the next row.
si.tiles.InvalidateAboveY((rowIndex+1)*si.cacheRowHeight - si.cacheRowYOffset)
si.oldCacheRowIndex = rowIndex
}
return si.cacheRows[rowIndex].RGBAAt(x, y)
} }
// Opaque returns whether the image is fully opaque. // Opaque returns whether the image is fully opaque.
@ -116,60 +144,3 @@ func (si *StitchedImage) Progress() (value, max int) {
return si.queryCounter, size.X * size.Y 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.blendMethod.Draw(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

@ -8,15 +8,45 @@ package main
import ( import (
"fmt" "fmt"
"image" "image"
"math"
"os" "os"
"sort"
"github.com/google/hilbert"
) )
// QuickSelect returns the kth smallest element of the given unsorted list.
// This is faster than sorting the list and then selecting the wanted element.
//
// Source: https://rosettacode.org/wiki/Quickselect_algorithm#Go
func QuickSelectUInt8(list []uint8, k int) uint8 {
for {
// Partition.
px := len(list) / 2
pv := list[px]
last := len(list) - 1
list[px], list[last] = list[last], list[px]
i := 0
for j := 0; j < last; j++ {
if list[j] < pv {
list[i], list[j] = list[j], list[i]
i++
}
}
// Select.
if i == k {
return pv
}
if k < i {
list = list[:i]
} else {
list[i], list[last] = list[last], list[i]
list = list[i+1:]
k -= i + 1
}
}
}
// Source: https://gist.github.com/sergiotapia/7882944 // Source: https://gist.github.com/sergiotapia/7882944
func getImageFileDimension(imagePath string) (int, int, error) { func GetImageFileDimension(imagePath string) (int, int, error) {
file, err := os.Open(imagePath) file, err := os.Open(imagePath)
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("can't open file %v: %w", imagePath, err) return 0, 0, fmt.Errorf("can't open file %v: %w", imagePath, err)
@ -31,9 +61,9 @@ func getImageFileDimension(imagePath string) (int, int, error) {
return image.Width, image.Height, nil return image.Width, image.Height, nil
} }
func gridifyRectangle(rect image.Rectangle, gridSize int) (result []image.Rectangle) { func GridifyRectangle(rect image.Rectangle, gridSize int) (result []image.Rectangle) {
for y := divideFloor(rect.Min.Y, gridSize); y < divideCeil(rect.Max.Y, gridSize); y++ { for y := DivideFloor(rect.Min.Y, gridSize); y <= DivideCeil(rect.Max.Y-1, gridSize); y++ {
for x := divideFloor(rect.Min.X, gridSize); x < divideCeil(rect.Max.X, gridSize); x++ { for x := DivideFloor(rect.Min.X, gridSize); x <= DivideCeil(rect.Max.X-1, gridSize); x++ {
tempRect := image.Rect(x*gridSize, y*gridSize, (x+1)*gridSize, (y+1)*gridSize) tempRect := image.Rect(x*gridSize, y*gridSize, (x+1)*gridSize, (y+1)*gridSize)
intersection := tempRect.Intersect(rect) intersection := tempRect.Intersect(rect)
if !intersection.Empty() { if !intersection.Empty() {
@ -45,33 +75,8 @@ func gridifyRectangle(rect image.Rectangle, gridSize int) (result []image.Rectan
return return
} }
func hilbertifyRectangle(rect image.Rectangle, gridSize int) ([]image.Rectangle, error) {
grid := gridifyRectangle(rect, gridSize)
gridX := divideFloor(rect.Min.X, gridSize)
gridY := divideFloor(rect.Min.Y, gridSize)
// Size of the grid in chunks
gridWidth := divideCeil(rect.Max.X, gridSize) - divideFloor(rect.Min.X, gridSize)
gridHeight := divideCeil(rect.Max.Y, gridSize) - divideFloor(rect.Min.Y, gridSize)
s, err := hilbert.NewHilbert(int(math.Pow(2, math.Ceil(math.Log2(math.Max(float64(gridWidth), float64(gridHeight)))))))
if err != nil {
return nil, err
}
sort.Slice(grid, func(i, j int) bool {
// Ignore out of range errors, as they shouldn't happen.
hilbertIndexA, _ := s.MapInverse(grid[i].Min.X/gridSize-gridX, grid[i].Min.Y/gridSize-gridY)
hilbertIndexB, _ := s.MapInverse(grid[j].Min.X/gridSize-gridX, grid[j].Min.Y/gridSize-gridY)
return hilbertIndexA < hilbertIndexB
})
return grid, nil
}
// Integer division that rounds to the next integer towards negative infinity. // Integer division that rounds to the next integer towards negative infinity.
func divideFloor(a, b int) int { func DivideFloor(a, b int) int {
temp := a / b temp := a / b
if ((a ^ b) < 0) && (a%b != 0) { if ((a ^ b) < 0) && (a%b != 0) {
@ -82,7 +87,7 @@ func divideFloor(a, b int) int {
} }
// Integer division that rounds to the next integer towards positive infinity. // Integer division that rounds to the next integer towards positive infinity.
func divideCeil(a, b int) int { func DivideCeil(a, b int) int {
temp := a / b temp := a / b
if ((a ^ b) >= 0) && (a%b != 0) { if ((a ^ b) >= 0) && (a%b != 0) {

5
go.mod
View File

@ -6,7 +6,6 @@ require (
github.com/1lann/promptui v0.8.1-0.20220708222609-81fad96dd5e1 github.com/1lann/promptui v0.8.1-0.20220708222609-81fad96dd5e1
github.com/cheggaaa/pb/v3 v3.1.0 github.com/cheggaaa/pb/v3 v3.1.0
github.com/coreos/go-semver v0.3.0 github.com/coreos/go-semver v0.3.0
github.com/google/hilbert v0.0.0-20181122061418-320f2e35a565
github.com/kbinani/screenshot v0.0.0-20210720154843-7d3a670d8329 github.com/kbinani/screenshot v0.0.0-20210720154843-7d3a670d8329
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/tdewolff/canvas v0.0.0-20220627195642-6566432f4b20 github.com/tdewolff/canvas v0.0.0-20220627195642-6566432f4b20
@ -21,7 +20,7 @@ require (
github.com/adrg/xdg v0.4.0 // indirect github.com/adrg/xdg v0.4.0 // indirect
github.com/benoitkugler/textlayout v0.1.3 // indirect github.com/benoitkugler/textlayout v0.1.3 // indirect
github.com/benoitkugler/textprocessing v0.0.2 // indirect github.com/benoitkugler/textprocessing v0.0.2 // indirect
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/chzyer/readline v1.5.1 // indirect
github.com/dsnet/compress v0.0.1 // indirect github.com/dsnet/compress v0.0.1 // indirect
github.com/fatih/color v1.13.0 // indirect github.com/fatih/color v1.13.0 // indirect
github.com/gen2brain/shm v0.0.0-20200228170931-49f9650110c5 // indirect github.com/gen2brain/shm v0.0.0-20200228170931-49f9650110c5 // indirect
@ -36,7 +35,7 @@ require (
github.com/tdewolff/minify/v2 v2.11.10 // indirect github.com/tdewolff/minify/v2 v2.11.10 // indirect
github.com/tdewolff/parse/v2 v2.6.0 // indirect github.com/tdewolff/parse/v2 v2.6.0 // indirect
golang.org/x/image v0.0.0-20220617043117-41969df76e82 // indirect golang.org/x/image v0.0.0-20220617043117-41969df76e82 // indirect
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect golang.org/x/sys v0.0.0-20220808155132-1c4a2a72c664 // indirect
golang.org/x/text v0.3.7 // indirect golang.org/x/text v0.3.7 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
) )

16
go.sum
View File

@ -27,12 +27,15 @@ github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl
github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927/go.mod h1:h/aW8ynjgkuj+NQRlZcDbAbM1ORAbXjXX77sX7T289U= github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927/go.mod h1:h/aW8ynjgkuj+NQRlZcDbAbM1ORAbXjXX77sX7T289U=
github.com/cheggaaa/pb/v3 v3.1.0 h1:3uouEsl32RL7gTiQsuaXD4Bzbfl5tGztXGUvXbs4O04= github.com/cheggaaa/pb/v3 v3.1.0 h1:3uouEsl32RL7gTiQsuaXD4Bzbfl5tGztXGUvXbs4O04=
github.com/cheggaaa/pb/v3 v3.1.0/go.mod h1:YjrevcBqadFDaGQKRdmZxTY42pXEqda48Ea3lt0K/BE= github.com/cheggaaa/pb/v3 v3.1.0/go.mod h1:YjrevcBqadFDaGQKRdmZxTY42pXEqda48Ea3lt0K/BE=
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -61,8 +64,6 @@ github.com/go-pdf/fpdf v0.5.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhO
github.com/go-pdf/fpdf v0.6.0 h1:MlgtGIfsdMEEQJr2le6b/HNr1ZlQwxyWr77r2aj2U/8= github.com/go-pdf/fpdf v0.6.0 h1:MlgtGIfsdMEEQJr2le6b/HNr1ZlQwxyWr77r2aj2U/8=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/hilbert v0.0.0-20181122061418-320f2e35a565 h1:KBAlCAY6eLC44FiEwbzEbHnpVlw15iVM4ZK8QpRIp4U=
github.com/google/hilbert v0.0.0-20181122061418-320f2e35a565/go.mod h1:xn6EodFfRzV6j8NXQRPjngeHWlrpOrsZPKuuLRThU1k=
github.com/jezek/xgb v0.0.0-20210312150743-0e0f116e1240 h1:dy+DS31tGEGCsZzB45HmJJNHjur8GDgtRNX9U7HnSX4= github.com/jezek/xgb v0.0.0-20210312150743-0e0f116e1240 h1:dy+DS31tGEGCsZzB45HmJJNHjur8GDgtRNX9U7HnSX4=
github.com/jezek/xgb v0.0.0-20210312150743-0e0f116e1240/go.mod h1:3P4UH/k22rXyHIJD2w4h2XMqPX4Of/eySEZq9L6wqc4= github.com/jezek/xgb v0.0.0-20210312150743-0e0f116e1240/go.mod h1:3P4UH/k22rXyHIJD2w4h2XMqPX4Of/eySEZq9L6wqc4=
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
@ -134,10 +135,11 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= golang.org/x/sys v0.0.0-20220808155132-1c4a2a72c664 h1:v1W7bwXHsnLLloWYTVEdvGvA7BHMeBYsPcF0GLDxIRs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220808155132-1c4a2a72c664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=