mirror of
https://github.com/Dadido3/noita-mapcap.git
synced 2024-11-18 17:17:31 +00:00
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:
parent
c3f841a4ff
commit
65f7cb4e60
@ -1 +0,0 @@
|
||||
go tool pprof -http=: ./stitch.exe cpu.prof
|
@ -30,6 +30,10 @@ example list of files:
|
||||
- Or run the program with parameters:
|
||||
- `divide int`
|
||||
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`
|
||||
The source path of the image tiles to be stitched. Defaults to "./..//..//output")
|
||||
- `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
|
||||
```
|
||||
|
||||
To enter the parameters inside of the program:
|
||||
To start the program interactively:
|
||||
|
||||
``` Shell Session
|
||||
./stitch
|
||||
|
@ -13,13 +13,14 @@ import (
|
||||
|
||||
// BlendMethodMedian takes the given tiles and median blends them into destImage.
|
||||
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) {
|
||||
bounds := destImage.Bounds()
|
||||
|
||||
if b.LimitToNew > 0 {
|
||||
if b.BlendTileLimit > 0 {
|
||||
// Sort tiles by date.
|
||||
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.
|
||||
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 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 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))
|
||||
rList, gList, bList = append(rList, col.R), append(gList, col.G), append(bList, col.B)
|
||||
count++
|
||||
// Limit number of tiles to median blend.
|
||||
// Will be ignored if LimitToNew is 0.
|
||||
if count == b.LimitToNew {
|
||||
// Will be ignored if the blend tile limit is 0.
|
||||
if count == b.BlendTileLimit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there were no images to get data from, ignore the pixel.
|
||||
if count == 0 {
|
||||
switch count {
|
||||
case 0: // If there were no images to get data from, ignore the pixel.
|
||||
continue
|
||||
}
|
||||
|
||||
// Sort colors. Not needed if there is only one color.
|
||||
if count > 1 {
|
||||
sort.Ints(rList)
|
||||
sort.Ints(gList)
|
||||
sort.Ints(bList)
|
||||
}
|
||||
case 1: // Only a single tile for this pixel.
|
||||
r, g, b := uint8(rList[0]), uint8(gList[0]), uint8(bList[0])
|
||||
destImage.SetRGBA(ix, iy, color.RGBA{r, g, b, 255})
|
||||
|
||||
// Take the middle element of each color.
|
||||
default: // Multiple overlapping tiles, median blend them.
|
||||
var r, g, b uint8
|
||||
switch count % 2 {
|
||||
case 0: // Even.
|
||||
r = uint8((rList[count/2-1] + rList[count/2]) / 2)
|
||||
g = uint8((gList[count/2-1] + gList[count/2]) / 2)
|
||||
b = uint8((bList[count/2-1] + bList[count/2]) / 2)
|
||||
r = uint8((int(QuickSelectUInt8(rList, count/2-1)) + int(QuickSelectUInt8(rList, count/2))) / 2)
|
||||
g = uint8((int(QuickSelectUInt8(gList, count/2-1)) + int(QuickSelectUInt8(gList, count/2))) / 2)
|
||||
b = uint8((int(QuickSelectUInt8(bList, count/2-1)) + int(QuickSelectUInt8(bList, count/2))) / 2)
|
||||
default: // Odd.
|
||||
r = uint8(rList[(count-1)/2])
|
||||
g = uint8(gList[(count-1)/2])
|
||||
b = uint8(bList[(count-1)/2])
|
||||
r = QuickSelectUInt8(rList, count/2)
|
||||
g = QuickSelectUInt8(gList, count/2)
|
||||
b = QuickSelectUInt8(bList, count/2)
|
||||
}
|
||||
|
||||
destImage.SetRGBA(ix, iy, color.RGBA{r, g, b, 255})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -8,103 +8,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")
|
||||
//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
|
||||
|
||||
func LoadEntities(path string) (Entities, error) {
|
||||
@ -123,124 +32,7 @@ func LoadEntities(path string) (Entities, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Draw implements the StitchedImageOverlay interface.
|
||||
func (e Entities) Draw(destImage *image.RGBA) {
|
||||
destRect := destImage.Bounds()
|
||||
|
||||
|
220
bin/stitch/entity.go
Normal file
220
bin/stitch/entity.go
Normal 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)
|
||||
}
|
@ -30,7 +30,9 @@ type ImageTile struct {
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
@ -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)
|
||||
}
|
||||
|
||||
width, height, err := getImageFileDimension(path)
|
||||
width, height, err := GetImageFileDimension(path)
|
||||
if err != nil {
|
||||
return ImageTile{}, err
|
||||
}
|
||||
@ -71,6 +73,8 @@ func NewImageTile(path string, scaleDivider int) (ImageTile, error) {
|
||||
scaleDivider: scaleDivider,
|
||||
image: image.Rect(x/scaleDivider, y/scaleDivider, (x+width)/scaleDivider, (y+height)/scaleDivider),
|
||||
imageMutex: &sync.RWMutex{},
|
||||
invalidationChan: make(chan struct{}, 1),
|
||||
timeoutChan: make(chan struct{}, 1),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -80,7 +84,11 @@ func NewImageTile(path string, scaleDivider int) (ImageTile, error) {
|
||||
func (it *ImageTile) GetImage() *image.RGBA {
|
||||
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.
|
||||
if img, ok := it.image.(*image.RGBA); ok {
|
||||
@ -128,13 +136,43 @@ func (it *ImageTile) GetImage() *image.RGBA {
|
||||
|
||||
it.image = imgRGBA
|
||||
|
||||
// Free the image after some time.
|
||||
go func() {
|
||||
for it.imageUsedFlag {
|
||||
it.imageUsedFlag = false
|
||||
time.Sleep(1000 * time.Millisecond)
|
||||
// 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() {
|
||||
// Set up watchdog that checks if the image is being used.
|
||||
ticker := time.NewTicker(5000 * 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()
|
||||
defer it.imageMutex.Unlock()
|
||||
it.image = it.image.Bounds()
|
||||
@ -143,6 +181,18 @@ func (it *ImageTile) GetImage() *image.RGBA {
|
||||
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.
|
||||
// This matches exactly to what GetImage() returns.
|
||||
func (it *ImageTile) Bounds() image.Rectangle {
|
||||
|
@ -10,13 +10,15 @@ import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type ImageTiles []ImageTile
|
||||
|
||||
// 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 {
|
||||
return nil, fmt.Errorf("invalid scale of %v", scaleDivider)
|
||||
}
|
||||
|
||||
var imageTiles []ImageTile
|
||||
var imageTiles ImageTiles
|
||||
|
||||
files, err := filepath.Glob(filepath.Join(path, "*.png"))
|
||||
if err != nil {
|
||||
@ -34,3 +36,12 @@ func LoadImageTiles(path string, scaleDivider int) ([]ImageTile, error) {
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 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 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 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 flagYMax = flag.Int("ymax", 0, "Lower bound of the output rectangle. This coordinate is not included in the output.")
|
||||
|
||||
func main() {
|
||||
log.Printf("Noita MapCapture stitching tool v%s", version)
|
||||
log.Printf("Noita MapCapture stitching tool v%s.", version)
|
||||
|
||||
flag.Parse()
|
||||
|
||||
@ -59,11 +60,38 @@ func main() {
|
||||
|
||||
result, err := prompt.Run()
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
if flag.NFlag() == 0 {
|
||||
prompt := promptui.Prompt{
|
||||
@ -74,7 +102,7 @@ func main() {
|
||||
|
||||
result, err := prompt.Run()
|
||||
if err != nil {
|
||||
log.Panicf("Error while getting user input: %v", err)
|
||||
log.Panicf("Error while getting user input: %v.", err)
|
||||
}
|
||||
*flagInputPath = result
|
||||
}
|
||||
@ -89,7 +117,7 @@ func main() {
|
||||
|
||||
result, err := prompt.Run()
|
||||
if err != nil {
|
||||
log.Panicf("Error while getting user input: %v", err)
|
||||
log.Panicf("Error while getting user input: %v.", err)
|
||||
}
|
||||
*flagEntitiesInputPath = result
|
||||
}
|
||||
@ -97,7 +125,7 @@ func main() {
|
||||
// Load entities if requested.
|
||||
entities, err := LoadEntities(*flagEntitiesInputPath)
|
||||
if err != nil {
|
||||
log.Printf("Failed to load entities: %v", err)
|
||||
log.Printf("Failed to load entities: %v.", err)
|
||||
}
|
||||
if len(entities) > 0 {
|
||||
log.Printf("Got %v entities.", len(entities))
|
||||
@ -114,7 +142,7 @@ func main() {
|
||||
|
||||
result, err := prompt.Run()
|
||||
if err != nil {
|
||||
log.Panicf("Error while getting user input: %v", err)
|
||||
log.Panicf("Error while getting user input: %v.", err)
|
||||
}
|
||||
*flagPlayerPathInputPath = result
|
||||
}
|
||||
@ -122,22 +150,22 @@ func main() {
|
||||
// Load player path if requested.
|
||||
playerPath, err := LoadPlayerPath(*flagPlayerPathInputPath)
|
||||
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 {
|
||||
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)
|
||||
log.Printf("Starting to read tile information at %q.", *flagInputPath)
|
||||
tiles, err := LoadImageTiles(*flagInputPath, *flagScaleDivider)
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
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{}
|
||||
for i, tile := range tiles {
|
||||
@ -147,17 +175,7 @@ func main() {
|
||||
totalBounds = totalBounds.Union(tile.Bounds())
|
||||
}
|
||||
}
|
||||
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()*/
|
||||
log.Printf("Total size of the possible output space is %v.", totalBounds)
|
||||
|
||||
// If the output rect is empty, use the rectangle that encloses all tiles.
|
||||
outputRect := image.Rect(*flagXMin, *flagYMin, *flagXMax, *flagYMax)
|
||||
@ -190,7 +208,7 @@ func main() {
|
||||
|
||||
result, err := prompt.Run()
|
||||
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
|
||||
fmt.Sscanf(result, "%d,%d;%d,%d", &xMin, &yMin, &xMax, &yMax)
|
||||
@ -207,32 +225,34 @@ func main() {
|
||||
|
||||
result, err := prompt.Run()
|
||||
if err != nil {
|
||||
log.Panicf("Error while getting user input: %v", err)
|
||||
log.Panicf("Error while getting user input: %v.", err)
|
||||
}
|
||||
*flagOutputPath = result
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
bar := pb.Full.New(0)
|
||||
var wg sync.WaitGroup
|
||||
done := make(chan struct{})
|
||||
|
||||
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 {
|
||||
log.Panicf("NewStitchedImage() failed: %v", err)
|
||||
log.Panicf("NewStitchedImage() failed: %v.", err)
|
||||
}
|
||||
_, 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.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
ticker := time.NewTicker(250 * time.Millisecond)
|
||||
for {
|
||||
select {
|
||||
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)
|
||||
if err != nil {
|
||||
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()
|
||||
log.Panic(err)
|
||||
}
|
||||
@ -264,6 +288,8 @@ func main() {
|
||||
if err := f.Close(); err != nil {
|
||||
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()
|
||||
}
|
||||
|
@ -53,6 +53,7 @@ func LoadPlayerPath(path string) (PlayerPath, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Draw implements the StitchedImageOverlay interface.
|
||||
func (p PlayerPath) Draw(destImage *image.RGBA) {
|
||||
destRect := destImage.Bounds()
|
||||
|
||||
|
21
bin/stitch/profiling.go
Normal file
21
bin/stitch/profiling.go
Normal 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)
|
||||
*/
|
||||
}
|
133
bin/stitch/stitched-image-cache.go
Normal file
133
bin/stitch/stitched-image-cache.go
Normal 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
|
||||
}
|
@ -9,19 +9,17 @@ 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
|
||||
var StitchedImageCacheGridSize = 256
|
||||
|
||||
// StitchedImageBlendMethod defines how tiles are blended together.
|
||||
type StitchedImageBlendMethod interface {
|
||||
Draw(tiles []*ImageTile, destImage *image.RGBA) // Draw is called when a new cache image is generated.
|
||||
}
|
||||
|
||||
// StitchedImageOverlay defines an interface for arbitrary overlays that can be drawn over the stitched image.
|
||||
type StitchedImageOverlay interface {
|
||||
Draw(*image.RGBA)
|
||||
}
|
||||
@ -29,37 +27,50 @@ type StitchedImageOverlay interface {
|
||||
// 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
|
||||
tiles ImageTiles
|
||||
bounds image.Rectangle
|
||||
blendMethod StitchedImageBlendMethod
|
||||
overlays []StitchedImageOverlay
|
||||
|
||||
cacheHeight int
|
||||
cacheImage *image.RGBA
|
||||
cacheRowHeight int
|
||||
cacheRows []StitchedImageCache
|
||||
cacheRowYOffset int // Defines the pixel offset of the first cache row.
|
||||
|
||||
oldCacheRowIndex int
|
||||
queryCounter int
|
||||
}
|
||||
|
||||
// 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() {
|
||||
return nil, fmt.Errorf("given boundaries are empty")
|
||||
}
|
||||
if blendMethod == nil {
|
||||
return nil, fmt.Errorf("no blending method given")
|
||||
}
|
||||
if cacheHeight <= 0 {
|
||||
return nil, fmt.Errorf("invalid cache height of %d pixels", cacheHeight)
|
||||
if cacheRowHeight <= 0 {
|
||||
return nil, fmt.Errorf("invalid cache row height of %d pixels", cacheRowHeight)
|
||||
}
|
||||
|
||||
return &StitchedImage{
|
||||
stitchedImage := &StitchedImage{
|
||||
tiles: tiles,
|
||||
bounds: bounds,
|
||||
blendMethod: blendMethod,
|
||||
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.
|
||||
@ -73,6 +84,10 @@ func (si *StitchedImage) Bounds() image.Rectangle {
|
||||
return si.bounds
|
||||
}
|
||||
|
||||
func (si *StitchedImage) At(x, y int) color.Color {
|
||||
return si.RGBAAt(x, y)
|
||||
}
|
||||
|
||||
// At returns the color of the pixel at (x, y).
|
||||
//
|
||||
// This is optimized to be read line by line (scanning), it will be much slower with random access.
|
||||
@ -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().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}
|
||||
|
||||
func (si *StitchedImage) RGBAAt(x, y int) color.RGBA {
|
||||
// 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)
|
||||
// Determine the cache rowIndex index.
|
||||
rowIndex := (y + si.cacheRowYOffset) / si.cacheRowHeight
|
||||
if rowIndex < 0 || rowIndex >= len(si.cacheRows) {
|
||||
return color.RGBA{}
|
||||
}
|
||||
|
||||
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.
|
||||
@ -116,60 +144,3 @@ func (si *StitchedImage) Progress() (value, max int) {
|
||||
|
||||
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
|
||||
}
|
||||
|
@ -8,15 +8,45 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"math"
|
||||
"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
|
||||
func getImageFileDimension(imagePath string) (int, int, error) {
|
||||
func GetImageFileDimension(imagePath string) (int, int, error) {
|
||||
file, err := os.Open(imagePath)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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 x := divideFloor(rect.Min.X, gridSize); x < divideCeil(rect.Max.X, gridSize); x++ {
|
||||
func GridifyRectangle(rect image.Rectangle, gridSize int) (result []image.Rectangle) {
|
||||
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-1, gridSize); x++ {
|
||||
tempRect := image.Rect(x*gridSize, y*gridSize, (x+1)*gridSize, (y+1)*gridSize)
|
||||
intersection := tempRect.Intersect(rect)
|
||||
if !intersection.Empty() {
|
||||
@ -45,33 +75,8 @@ func gridifyRectangle(rect image.Rectangle, gridSize int) (result []image.Rectan
|
||||
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.
|
||||
func divideFloor(a, b int) int {
|
||||
func DivideFloor(a, b int) int {
|
||||
temp := a / b
|
||||
|
||||
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.
|
||||
func divideCeil(a, b int) int {
|
||||
func DivideCeil(a, b int) int {
|
||||
temp := a / b
|
||||
|
||||
if ((a ^ b) >= 0) && (a%b != 0) {
|
||||
|
5
go.mod
5
go.mod
@ -6,7 +6,6 @@ require (
|
||||
github.com/1lann/promptui v0.8.1-0.20220708222609-81fad96dd5e1
|
||||
github.com/cheggaaa/pb/v3 v3.1.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/nfnt/resize v0.0.0-20180221191011-83c6a9932646
|
||||
github.com/tdewolff/canvas v0.0.0-20220627195642-6566432f4b20
|
||||
@ -21,7 +20,7 @@ require (
|
||||
github.com/adrg/xdg v0.4.0 // indirect
|
||||
github.com/benoitkugler/textlayout v0.1.3 // 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/fatih/color v1.13.0 // 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/parse/v2 v2.6.0 // 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
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
16
go.sum
16
go.sum
@ -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/cheggaaa/pb/v3 v3.1.0 h1:3uouEsl32RL7gTiQsuaXD4Bzbfl5tGztXGUvXbs4O04=
|
||||
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/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/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 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/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
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/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/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/go.mod h1:3P4UH/k22rXyHIJD2w4h2XMqPX4Of/eySEZq9L6wqc4=
|
||||
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-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-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-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-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220808155132-1c4a2a72c664 h1:v1W7bwXHsnLLloWYTVEdvGvA7BHMeBYsPcF0GLDxIRs=
|
||||
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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
|
Loading…
Reference in New Issue
Block a user