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:
|
- 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
|
||||||
|
@ -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})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
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.
|
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
|
||||||
}
|
}
|
||||||
@ -71,6 +73,8 @@ func NewImageTile(path string, scaleDivider int) (ImageTile, error) {
|
|||||||
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.
|
||||||
go func() {
|
select {
|
||||||
for it.imageUsedFlag {
|
case <-it.invalidationChan:
|
||||||
it.imageUsedFlag = false
|
default:
|
||||||
time.Sleep(1000 * time.Millisecond)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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()
|
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 {
|
||||||
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
|
@ -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
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"
|
"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.
|
||||||
|
|
||||||
|
oldCacheRowIndex int
|
||||||
queryCounter 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
|
|
||||||
}
|
|
||||||
|
@ -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
5
go.mod
@ -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
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/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=
|
||||||
|
Loading…
Reference in New Issue
Block a user