// Copyright (c) 2019-2022 David Vogel // // This software is released under the MIT License. // https://opensource.org/licenses/MIT package main import ( "flag" "fmt" "image" "image/png" "log" "os" "path/filepath" "sync" "time" "github.com/1lann/promptui" "github.com/cheggaaa/pb/v3" ) var flagInputPath = flag.String("input", filepath.Join(".", "..", "..", "output"), "The source path of the image tiles to be stitched.") var flagEntitiesInputPath = flag.String("entities", filepath.Join(".", "..", "..", "output", "entities.json"), "The path to the entities.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 flagScaleDivider = flag.Int("divide", 1, "A downscaling factor. 2 will produce an image with half the side lengths.") 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.") var flagCleanupThreshold = flag.Float64("cleanup", 0, "Enable cleanup mode with the given threshold. This will DELETE images from the input folder, no stitching will be done in this mode. A good value to start with is 0.999, which deletes images where the sum of the min-max difference of each sub-pixel overlapping with other images is less than 99.9%% of the maximum possible sum of pixel differences.") func main() { log.Printf("Noita MapCapture stitching tool v%s", version) flag.Parse() var overlays []StitchedImageOverlay // Query the user, if there were no cmd arguments given. if flag.NFlag() == 0 { prompt := promptui.Prompt{ Label: "Enter downscaling factor:", Default: fmt.Sprint(*flagScaleDivider), AllowEdit: true, Validate: func(s string) error { var num int _, err := fmt.Sscanf(s, "%d", &num) if err != nil { return err } if int(num) < 1 { return fmt.Errorf("number must be larger than 0") } return nil }, } result, err := prompt.Run() if err != nil { 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 input path:", Default: *flagInputPath, AllowEdit: true, } result, err := prompt.Run() if err != nil { log.Panicf("Error while getting user input: %v", err) } *flagInputPath = result } // Query the user, if there were no cmd arguments given. if flag.NFlag() == 0 { prompt := promptui.Prompt{ Label: "Enter \"entities.json\" path:", Default: *flagEntitiesInputPath, AllowEdit: true, } result, err := prompt.Run() if err != nil { log.Panicf("Error while getting user input: %v", err) } *flagEntitiesInputPath = result } // Load entities if requested. entities, err := LoadEntities(*flagEntitiesInputPath) if err != nil { log.Printf("Failed to load entities: %v", err) } if len(entities) > 0 { log.Printf("Got %v entities.", len(entities)) overlays = append(overlays, entities) // Add entities to overlay drawing list. } // Query the user, if there were no cmd arguments given. if flag.NFlag() == 0 { prompt := promptui.Prompt{ Label: "Enter \"player-path.json\" path:", Default: *flagPlayerPathInputPath, AllowEdit: true, } result, err := prompt.Run() if err != nil { log.Panicf("Error while getting user input: %v", err) } *flagPlayerPathInputPath = result } // Load player path if requested. playerPath, err := LoadPlayerPath(*flagPlayerPathInputPath) if err != nil { 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) 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.Printf("Got %v tiles", len(tiles)) totalBounds := image.Rectangle{} for i, tile := range tiles { if i == 0 { totalBounds = tile.Bounds() } else { 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()*/ // If the output rect is empty, use the rectangle that encloses all tiles. outputRect := image.Rect(*flagXMin, *flagYMin, *flagXMax, *flagYMax) if outputRect.Empty() { outputRect = totalBounds } // Query the user, if there were no cmd arguments given. if flag.NFlag() == 0 { prompt := promptui.Prompt{ Label: "Enter output rectangle (xMin,yMin;xMax,yMax):", Default: fmt.Sprintf("%d,%d;%d,%d", outputRect.Min.X, outputRect.Min.Y, outputRect.Max.X, outputRect.Max.Y), AllowEdit: true, Validate: func(s string) error { var xMin, yMin, xMax, yMax int _, err := fmt.Sscanf(s, "%d,%d;%d,%d", &xMin, &yMin, &xMax, &yMax) if err != nil { return err } rect := image.Rect(xMin, yMin, xMax, yMax) if rect.Empty() { return fmt.Errorf("rectangle must not be empty") } outputRect = rect return nil }, } result, err := prompt.Run() if err != nil { 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) outputRect = image.Rect(xMin, yMin, xMax, yMax) } // Query the user, if there were no cmd arguments given. /*if flag.NFlag() == 0 { fmt.Println("\nYou can now define a cleanup threshold. This mode will DELETE input images based on their similarity with other overlapping input images. The range is from 0, where no images are deleted, to 1 where all images will be deleted. A good value to get rid of most artifacts is 0.999. If you enter a threshold above 0, the program will not stitch, but DELETE some of your input images. If you want to stitch, enter 0.") prompt := promptui.Prompt{ Label: "Enter cleanup threshold:", Default: strconv.FormatFloat(*flagCleanupThreshold, 'f', -1, 64), AllowEdit: true, Validate: func(s string) error { result, err := strconv.ParseFloat(s, 64) if err != nil { return err } if result < 0 || result > 1 { return fmt.Errorf("Number %v outside of valid range [0;1]", result) } return nil }, } result, err := prompt.Run() if err != nil { log.Panicf("Error while getting user input: %v", err) } *flagCleanupThreshold, err = strconv.ParseFloat(result, 64) if err != nil { log.Panicf("Error while parsing user input: %v", err) } }*/ if *flagCleanupThreshold < 0 || *flagCleanupThreshold > 1 { log.Panicf("Cleanup threshold (%v) outside of valid range [0;1]", *flagCleanupThreshold) } if *flagCleanupThreshold > 0 { bar := pb.Full.New(0) log.Printf("Cleaning up %v tiles at %v", len(tiles), outputRect) if err := CompareGrid(tiles, outputRect, 512, bar); err != nil { log.Panic(err) } bar.Finish() for _, tile := range tiles { pixelErrorSumNormalized := float64(tile.pixelErrorSum) / float64(tile.Bounds().Size().X*tile.Bounds().Size().Y*3*255) if 1-pixelErrorSumNormalized <= *flagCleanupThreshold { os.Remove(tile.fileName) log.Printf("Tile %v has matching factor of %f. Deleted file!", &tile, 1-pixelErrorSumNormalized) } else { log.Printf("Tile %v has matching factor of %f", &tile, 1-pixelErrorSumNormalized) } } return } // Query the user, if there were no cmd arguments given. if flag.NFlag() == 0 { prompt := promptui.Prompt{ Label: "Enter output filename and path:", Default: *flagOutputPath, AllowEdit: true, } result, err := prompt.Run() if err != nil { log.Panicf("Error while getting user input: %v", err) } *flagOutputPath = result } 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. } outputImage, err := NewStitchedImage(tiles, outputRect, blendMethod, 512, overlays) if err != nil { log.Panicf("NewStitchedImage() failed: %v", err) } _, max := outputImage.Progress() bar.SetTotal(int64(max)).Start().SetRefreshRate(1 * time.Second) // Query progress and draw progress bar. wg.Add(1) go func() { defer wg.Done() ticker := time.NewTicker(1 * time.Second) for { select { case <-done: value, _ := outputImage.Progress() bar.SetCurrent(int64(value)) bar.Finish() return case <-ticker.C: value, _ := outputImage.Progress() bar.SetCurrent(int64(value)) } } }() log.Printf("Creating output file \"%v\"", *flagOutputPath) f, err := os.Create(*flagOutputPath) if err != nil { log.Panic(err) } if err := png.Encode(f, outputImage); err != nil { f.Close() log.Panic(err) } done <- struct{}{} wg.Wait() if err := f.Close(); err != nil { log.Panic(err) } log.Printf("Created output file \"%v\"", *flagOutputPath) }