├── main.go ├── go.mod ├── .github └── workflows │ ├── build.yml │ └── release.yml ├── LICENSE ├── go.sum ├── README.md ├── conv ├── img.go └── convert.go ├── .gitignore └── cmd └── root.go /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/blackironj/panorama/cmd" 5 | ) 6 | 7 | func main() { 8 | cmd.Execute() 9 | } 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/blackironj/panorama 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/gosuri/uilive v0.0.4 7 | github.com/spf13/cobra v1.8.0 8 | ) 9 | 10 | require ( 11 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 12 | github.com/mattn/go-isatty v0.0.20 // indirect 13 | github.com/spf13/pflag v1.0.5 // indirect 14 | golang.org/x/sys v0.16.0 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [ master ] 4 | pull_request: 5 | branches: [ master ] 6 | name: build 7 | jobs: 8 | build: 9 | strategy: 10 | matrix: 11 | go-version: [1.21.x] 12 | platform: [ubuntu-latest, macos-latest, windows-latest] 13 | runs-on: ${{ matrix.platform }} 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Setup Go 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: ${{ matrix.go-version }} 21 | 22 | - name: Build 23 | run: | 24 | go build -v . -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 blackironj 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 2 | github.com/gosuri/uilive v0.0.4 h1:hUEBpQDj8D8jXgtCdBu7sWsy5sbW/5GhuO8KBwJ2jyY= 3 | github.com/gosuri/uilive v0.0.4/go.mod h1:V/epo5LjjlDE5RJUcqx8dbw+zc93y5Ya3yg8tfZ74VI= 4 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 5 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 6 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 7 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 8 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 9 | github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= 10 | github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 11 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 12 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 13 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 14 | golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= 15 | golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 16 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 17 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Pipeline 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build-publish: 9 | name: Build binary 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | include: 14 | - build-goos: linux 15 | build-arch: amd64 16 | ext: '' 17 | - build-goos: windows 18 | build-arch: amd64 19 | ext: '.exe' 20 | - build-goos: darwin 21 | build-arch: amd64 22 | ext: '' 23 | - build-goos: darwin 24 | build-arch: arm64 25 | ext: '' 26 | 27 | steps: 28 | - name: Setup go 29 | uses: actions/setup-go@v5 30 | with: 31 | go-version: 1.21.x 32 | 33 | - uses: actions/checkout@v4 34 | - uses: actions/cache@v4 35 | with: 36 | path: | 37 | ~/go/pkg/mod 38 | ~/.cache/go-build 39 | key: ${{ matrix.build-goos }}-go-${{ hashFiles('**/go.sum') }} 40 | restore-keys: | 41 | ${{ matrix.build-goos }}-go- 42 | 43 | - name: Set release output name 44 | id: set-tag 45 | run: | 46 | echo "OUTPUT_NAME=panorama-${{ github.ref_name }}-${{ matrix.build-goos }}-${{ matrix.build-arch }}${{ matrix.ext }}" >> $GITHUB_OUTPUT 47 | 48 | - name: Build 49 | run: | 50 | CGO_ENABLED=0 GOOS=${{ matrix.build-goos }} GOARCH=${{ matrix.build-arch }} go build -o "${{ steps.set-tag.outputs.OUTPUT_NAME }}" . 51 | 52 | - name: Publish binary 53 | uses: svenstaro/upload-release-action@v2 54 | with: 55 | repo_token: ${{ secrets.GH_TOKEN }} 56 | file: "${{ steps.set-tag.outputs.OUTPUT_NAME }}" 57 | tag: ${{ github.ref }} 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Equirectangular panorama to Cubemap 2 | 3 | Porting c++ to go from 4 | 5 | Convert an equirectangular panorama image into cubemap image. this simple app is written by Go 6 | 7 | ## Screenshot 8 | 9 | ![example](https://user-images.githubusercontent.com/43738420/112742708-bf90c100-8fcb-11eb-8159-cecaf834ef2c.png) 10 | > Image source: Timothy Oldfield on Unsplash 11 | 12 | ### Usage 13 | 14 | It is possible to convert **JPEG** and **PNG** image format 15 | 16 | ``` sh 17 | Usage: 18 | panorama [flags] 19 | 20 | Flags: 21 | -h, --help help for panorama 22 | -i, --in string input image file path (required if --indir is not specified) 23 | -d, --indir string input directory path (required if --in is not specified) 24 | -l, --len int edge length of a cube face (default 1024) 25 | -o, --out string out file dir path (default ".") 26 | -s, --sides array list of sides splited by "," (optional) 27 | -q, --quality int jpeg file output quality ranges from 1 to 100 inclusive, higher is better (optional, default 75) 28 | ``` 29 | 30 | ``` sh 31 | # example 32 | ./panorama --in ./sample_image.jpg --out ./dist --len 512 --sides left,right,top,bottom,front,back 33 | ``` 34 | 35 | ### Installation 36 | 37 | ``` sh 38 | git clone https://github.com/blackironj/panorama.gitgit clone 39 | 40 | cd panorama 41 | 42 | go build -o panorama 43 | ``` 44 | 45 | Or [Download here](https://github.com/blackironj/panorama/releases/tag/1.0) 46 | 47 | ### TODO 48 | 49 | - Optimize code 50 | - It uses 1 go-routine per each face to convert. (use 6 go-routines) 51 | - Add more interpolation algorithms 52 | -------------------------------------------------------------------------------- /conv/img.go: -------------------------------------------------------------------------------- 1 | package conv 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "image" 7 | "image/jpeg" 8 | "image/png" 9 | "os" 10 | "path/filepath" 11 | ) 12 | 13 | const faceLen = 6 14 | 15 | var faceMap = map[int]string{ 16 | 0: "back", 17 | 1: "left", 18 | 2: "front", 19 | 3: "right", 20 | 4: "top", 21 | 5: "bottom", 22 | } 23 | 24 | var revesedFaceMap = map[string]int{ 25 | "back": 0, 26 | "left": 1, 27 | "front": 2, 28 | "right": 3, 29 | "top": 4, 30 | "bottom": 5, 31 | } 32 | 33 | func ReadImage(imagePath string) (image.Image, string, error) { 34 | if _, err := os.Stat(imagePath); os.IsNotExist(err) { 35 | return nil, "", fmt.Errorf("file does not exist: %s", imagePath) 36 | } 37 | 38 | imgFile, err := os.Open(imagePath) 39 | if err != nil { 40 | return nil, "", fmt.Errorf("error opening file: %s", err) 41 | } 42 | defer imgFile.Close() 43 | 44 | imgIn, ext, err := image.Decode(imgFile) 45 | if err != nil { 46 | return nil, "", fmt.Errorf("error decoding image: %s", err) 47 | } 48 | 49 | if ext == "jpg" || ext == "jpeg" || ext == "png" { 50 | return imgIn, ext, nil 51 | } 52 | 53 | return nil, "", errors.New("unsupported image format: " + ext) 54 | } 55 | 56 | func WriteImage(canvases []*image.RGBA, writeDirPath, imgExt string, sides []string, quality int) error { 57 | if len(canvases) != len(sides) { 58 | return errors.New("mismatched face size and sides length") 59 | } 60 | 61 | if _, err := os.Stat(writeDirPath); os.IsNotExist(err) { 62 | if err := os.MkdirAll(writeDirPath, os.ModePerm); err != nil { 63 | return err 64 | } 65 | } 66 | 67 | // Treat "jpg" as "jpeg" 68 | if imgExt == "jpg" { 69 | imgExt = "jpeg" 70 | } 71 | 72 | for i := 0; i < len(canvases); i++ { 73 | side := sides[i] 74 | path := filepath.Join(writeDirPath, side+"."+imgExt) 75 | newFile, _ := os.Create(path) 76 | 77 | switch imgExt { 78 | case "jpeg": 79 | opt := jpeg.Options{Quality: quality} 80 | if err := jpeg.Encode(newFile, canvases[i], &opt); err != nil { 81 | return err 82 | } 83 | case "png": 84 | if err := png.Encode(newFile, canvases[i]); err != nil { 85 | return err 86 | } 87 | default: 88 | return errors.New("unsupported image file format: " + imgExt) 89 | } 90 | } 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/jetbrains,vscode 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=jetbrains,vscode 3 | 4 | ### JetBrains ### 5 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 6 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 7 | 8 | # User-specific stuff 9 | .idea/**/workspace.xml 10 | .idea/**/tasks.xml 11 | .idea/**/usage.statistics.xml 12 | .idea/**/dictionaries 13 | .idea/**/shelf 14 | 15 | # Generated files 16 | .idea/**/contentModel.xml 17 | 18 | # Sensitive or high-churn files 19 | .idea/**/dataSources/ 20 | .idea/**/dataSources.ids 21 | .idea/**/dataSources.local.xml 22 | .idea/**/sqlDataSources.xml 23 | .idea/**/dynamic.xml 24 | .idea/**/uiDesigner.xml 25 | .idea/**/dbnavigator.xml 26 | 27 | # Gradle 28 | .idea/**/gradle.xml 29 | .idea/**/libraries 30 | 31 | # Gradle and Maven with auto-import 32 | # When using Gradle or Maven with auto-import, you should exclude module files, 33 | # since they will be recreated, and may cause churn. Uncomment if using 34 | # auto-import. 35 | # .idea/artifacts 36 | # .idea/compiler.xml 37 | # .idea/jarRepositories.xml 38 | # .idea/modules.xml 39 | # .idea/*.iml 40 | # .idea/modules 41 | # *.iml 42 | # *.ipr 43 | 44 | # CMake 45 | cmake-build-*/ 46 | 47 | # Mongo Explorer plugin 48 | .idea/**/mongoSettings.xml 49 | 50 | # File-based project format 51 | *.iws 52 | 53 | # IntelliJ 54 | out/ 55 | 56 | # mpeltonen/sbt-idea plugin 57 | .idea_modules/ 58 | 59 | # JIRA plugin 60 | atlassian-ide-plugin.xml 61 | 62 | # Cursive Clojure plugin 63 | .idea/replstate.xml 64 | 65 | # Crashlytics plugin (for Android Studio and IntelliJ) 66 | com_crashlytics_export_strings.xml 67 | crashlytics.properties 68 | crashlytics-build.properties 69 | fabric.properties 70 | 71 | # Editor-based Rest Client 72 | .idea/httpRequests 73 | 74 | # Android studio 3.1+ serialized cache file 75 | .idea/caches/build_file_checksums.ser 76 | 77 | ### JetBrains Patch ### 78 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 79 | 80 | # *.iml 81 | # modules.xml 82 | # .idea/misc.xml 83 | # *.ipr 84 | 85 | # Sonarlint plugin 86 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 87 | .idea/**/sonarlint/ 88 | 89 | # SonarQube Plugin 90 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 91 | .idea/**/sonarIssues.xml 92 | 93 | # Markdown Navigator plugin 94 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 95 | .idea/**/markdown-navigator.xml 96 | .idea/**/markdown-navigator-enh.xml 97 | .idea/**/markdown-navigator/ 98 | 99 | # Cache file creation bug 100 | # See https://youtrack.jetbrains.com/issue/JBR-2257 101 | .idea/$CACHE_FILE$ 102 | 103 | # CodeStream plugin 104 | # https://plugins.jetbrains.com/plugin/12206-codestream 105 | .idea/codestream.xml 106 | 107 | ### vscode ### 108 | .vscode/* 109 | 110 | ### exe ### 111 | panorama 112 | panorama.exe 113 | 114 | ### test file 115 | testfile* 116 | testdir 117 | dist 118 | 119 | # End of https://www.toptal.com/developers/gitignore/api/jetbrains,vscode -------------------------------------------------------------------------------- /conv/convert.go: -------------------------------------------------------------------------------- 1 | package conv 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | "log" 7 | "math" 8 | "sync" 9 | ) 10 | 11 | const Pi_2 = math.Pi / 2.0 12 | 13 | type Number interface { 14 | uint32 | float64 15 | } 16 | 17 | type Vec3[T Number] struct { 18 | X, Y, Z T 19 | } 20 | 21 | func outImgToXYZ(i, j, face, edge int, inLen float64) Vec3[float64] { 22 | a := inLen*float64(i) - 1.0 23 | b := inLen*float64(j) - 1.0 24 | 25 | var res Vec3[float64] 26 | switch face { 27 | case 0: //back 28 | res = Vec3[float64]{-1.0, -a, -b} 29 | case 1: //left 30 | res = Vec3[float64]{a, -1.0, -b} 31 | case 2: //front 32 | res = Vec3[float64]{1.0, a, -b} 33 | case 3: //right 34 | res = Vec3[float64]{-a, 1.0, -b} 35 | case 4: //top 36 | res = Vec3[float64]{b, a, 1.0} 37 | case 5: //bottom 38 | res = Vec3[float64]{-b, a, -1.0} 39 | default: 40 | log.Fatal("Wrong face") 41 | } 42 | return res 43 | } 44 | 45 | func interpolateXYZtoColor(xyz Vec3[float64], imgIn image.Image, sw, sh int) Vec3[uint32] { 46 | theta := math.Atan2(xyz.Y, xyz.X) 47 | rad := math.Hypot(xyz.X, xyz.Y) // range -pi to pi 48 | phi := math.Atan2(xyz.Z, rad) // range -pi/2 to pi/2 49 | 50 | //source img coords 51 | dividedH := float64(sh) / math.Pi 52 | uf := (theta + math.Pi) * dividedH 53 | vf := (Pi_2 - phi) * dividedH 54 | 55 | // Use bilinear interpolation between the four surrounding pixels 56 | ui := safeIndex(math.Floor(uf), float64(sw)) 57 | vi := safeIndex(math.Floor(vf), float64(sh)) 58 | u2 := safeIndex(float64(ui)+1.0, float64(sw)) 59 | v2 := safeIndex(float64(vi)+1.0, float64(sh)) 60 | 61 | mu := uf - float64(ui) 62 | nu := vf - float64(vi) 63 | 64 | read := func(x, y int) Vec3[float64] { 65 | red, green, blue, _ := imgIn.At(x, y).RGBA() 66 | return Vec3[float64]{ 67 | X: float64(red >> 8), 68 | Y: float64(green >> 8), 69 | Z: float64(blue >> 8), 70 | } 71 | } 72 | 73 | A := read(ui, vi) 74 | B := read(u2, vi) 75 | C := read(ui, v2) 76 | D := read(u2, v2) 77 | 78 | val := mix(mix(A, B, mu), mix(C, D, mu), nu) 79 | return Vec3[uint32]{ 80 | X: uint32(val.X), 81 | Y: uint32(val.Y), 82 | Z: uint32(val.Z), 83 | } 84 | } 85 | 86 | func ConvertEquirectangularToCubeMap(rValue int, imgIn image.Image, sides []string) []*image.RGBA { 87 | sw := imgIn.Bounds().Max.X 88 | sh := imgIn.Bounds().Max.Y 89 | sidesCount := len(sides) 90 | var sidesInt []int 91 | 92 | for i := 0; i < sidesCount; i++ { 93 | sidesInt = append(sidesInt, revesedFaceMap[sides[i]]) 94 | } 95 | var wg sync.WaitGroup 96 | 97 | canvases := make([]*image.RGBA, sidesCount) 98 | for i := 0; i < sidesCount; i++ { 99 | canvases[i] = image.NewRGBA(image.Rect(0, 0, rValue, rValue)) 100 | } 101 | 102 | for i := 0; i < sidesCount; i++ { 103 | wg.Add(1) 104 | side := sidesInt[i] 105 | canvas := canvases[i] 106 | 107 | go func(side int, canvas *image.RGBA) { 108 | defer wg.Done() 109 | convert(rValue, side, sw, sh, imgIn, canvas) 110 | }(side, canvas) 111 | } 112 | wg.Wait() 113 | 114 | return canvases 115 | } 116 | 117 | func convert(edge, face, sw, sh int, imgIn image.Image, imgOut *image.RGBA) { 118 | inLen := 2.0 / float64(edge) 119 | 120 | for i := 0; i < edge; i++ { 121 | for j := 0; j < edge; j++ { 122 | xyz := outImgToXYZ(i, j, face, edge, inLen) 123 | clr := interpolateXYZtoColor(xyz, imgIn, sw, sh) 124 | 125 | imgOut.Set(i, j, color.RGBA{uint8(clr.X), uint8(clr.Y), uint8(clr.Z), 255}) 126 | } 127 | } 128 | } 129 | 130 | func safeIndex(n, size float64) int { 131 | return int(math.Min(math.Max(n, 0), size-1)) 132 | } 133 | 134 | func mix(one, other Vec3[float64], c float64) Vec3[float64] { 135 | x := (other.X-one.X)*c + one.X 136 | y := (other.Y-one.Y)*c + one.Y 137 | z := (other.Z-one.Z)*c + one.Z 138 | 139 | return Vec3[float64]{ 140 | X: x, 141 | Y: y, 142 | Z: z, 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "io/fs" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/gosuri/uilive" 14 | "github.com/spf13/cobra" 15 | 16 | "github.com/blackironj/panorama/conv" 17 | ) 18 | 19 | const ( 20 | defaultEdgeLen = 1024 21 | maxConcurrentFiles = 10 // Adjust this number based on your system's file descriptor limit 22 | defaultJpegQuality = 75 23 | ) 24 | 25 | var ( 26 | inFilePath string 27 | outFileDir string 28 | inDirPath string 29 | edgeLen int 30 | sides []string 31 | quality int 32 | 33 | validSides = []string{"front", "back", "left", "right", "top", "bottom"} 34 | semaphore = make(chan struct{}, maxConcurrentFiles) 35 | progress = struct { 36 | sync.Mutex 37 | totalFiles int 38 | processedFiles int 39 | startTime time.Time 40 | errors []string 41 | }{} 42 | rootCmd = &cobra.Command{ 43 | Use: "panorama", 44 | Short: "convert equirectangular panorama img to Cubemap img", 45 | Run: func(cmd *cobra.Command, args []string) { 46 | if inFilePath == "" && inDirPath == "" { 47 | er("Need an input image file path or input directory") 48 | } 49 | if len(inFilePath) > 0 && len(inDirPath) > 0 { 50 | er("Need only one path, not both") 51 | } 52 | 53 | progress.startTime = time.Now() 54 | fmt.Println("Start conversion.") 55 | if inFilePath != "" { 56 | progress.totalFiles = 1 57 | processSingleImage(inFilePath, outFileDir, false) 58 | } else { 59 | processDirectory(inDirPath, outFileDir) 60 | } 61 | elapsed := time.Since(progress.startTime).Seconds() 62 | fmt.Printf("Processing complete. elapsed: %.2f sec\n\n", elapsed) 63 | 64 | if len(progress.errors) > 0 { 65 | fmt.Println("\nErrors:") 66 | for _, err := range progress.errors { 67 | fmt.Println(err) 68 | } 69 | } 70 | }, 71 | } 72 | ) 73 | 74 | func init() { 75 | rootCmd.Flags().StringVarP(&inFilePath, "in", "i", "", "input image file path (required if --indir is not specified)") 76 | rootCmd.Flags().StringVarP(&inDirPath, "indir", "d", "", "input directory path (required if --in is not specified)") 77 | rootCmd.Flags().StringVarP(&outFileDir, "out", "o", ".", "output file directory path") 78 | rootCmd.Flags().IntVarP(&edgeLen, "len", "l", defaultEdgeLen, "edge length of a cube face") 79 | rootCmd.Flags().StringSliceVarP(&sides, "sides", "s", []string{}, "array of sides [front,back,left,right,top,bottom] (default: all sides)") 80 | rootCmd.Flags().IntVarP(&quality, "quality", "q", defaultJpegQuality, "jpeg file output quality ranges from 1 to 100 inclusive, higher is better") 81 | } 82 | 83 | func processSingleImage(inPath, outDir string, needSubdir bool) { 84 | semaphore <- struct{}{} // Acquire a semaphore 85 | defer func() { <-semaphore }() // Release the semaphore when done 86 | 87 | inImage, ext, err := conv.ReadImage(inPath) 88 | if err != nil { 89 | progress.Lock() 90 | progress.errors = append(progress.errors, fmt.Sprintf("Error reading image %s: %v", inPath, err)) 91 | progress.Unlock() 92 | return 93 | } 94 | 95 | if len(sides) == 0 { 96 | sides = validSides 97 | } else { 98 | for _, side := range sides { 99 | if !isValidSide(side) { 100 | er(fmt.Sprintf("Invalid side specified: %s. Valid sides are %v", side, validSides)) 101 | } 102 | } 103 | } 104 | 105 | canvases, err := safeConvertEquirectangularToCubeMap(edgeLen, inImage, sides) 106 | 107 | if err != nil { 108 | progress.Lock() 109 | progress.errors = append(progress.errors, fmt.Sprintf("Error converting image %s: %v", inPath, err)) 110 | progress.Unlock() 111 | return 112 | } 113 | 114 | if needSubdir { 115 | outDir = filepath.Join(outDir, strings.TrimSuffix(filepath.Base(inPath), filepath.Ext(inPath))) 116 | } 117 | if err := conv.WriteImage(canvases, outDir, ext, sides, quality); err != nil { 118 | progress.Lock() 119 | progress.errors = append(progress.errors, fmt.Sprintf("Error writing images for %s: %v", inPath, err)) 120 | progress.Unlock() 121 | return 122 | } 123 | 124 | progress.Lock() 125 | progress.processedFiles++ 126 | progress.Unlock() 127 | } 128 | 129 | func processDirectory(inDir, outDir string) { 130 | files, err := os.ReadDir(inDir) 131 | if err != nil { 132 | er(err) 133 | } 134 | 135 | progress.totalFiles = len(files) 136 | 137 | writer := uilive.New() 138 | writer.Start() 139 | defer writer.Stop() 140 | 141 | var wg sync.WaitGroup 142 | for _, file := range files { 143 | if !file.IsDir() && isImageFile(file) { 144 | wg.Add(1) 145 | go func(file fs.DirEntry) { 146 | defer wg.Done() 147 | inPath := filepath.Join(inDir, file.Name()) 148 | processSingleImage(inPath, outDir, true) 149 | updateProgress(writer) 150 | }(file) 151 | } 152 | } 153 | 154 | go func() { 155 | for { 156 | time.Sleep(1 * time.Second) 157 | updateProgress(writer) 158 | progress.Lock() 159 | remaining := progress.totalFiles - progress.processedFiles 160 | if remaining <= 0 { 161 | progress.Unlock() 162 | break 163 | } 164 | progress.Unlock() 165 | } 166 | }() 167 | 168 | wg.Wait() 169 | } 170 | 171 | func updateProgress(writer *uilive.Writer) { 172 | progress.Lock() 173 | defer progress.Unlock() 174 | remaining := progress.totalFiles - progress.processedFiles 175 | elapsed := time.Since(progress.startTime).Seconds() 176 | eta := float64(remaining) / (float64(progress.processedFiles) / elapsed) 177 | fmt.Fprintf(writer, "Progress: %d/%d files processed. ETA: %.2f seconds. IT/S: %.2f\n", progress.processedFiles, progress.totalFiles, eta, float64(progress.processedFiles)/elapsed) 178 | } 179 | 180 | func isImageFile(file fs.DirEntry) bool { 181 | ext := strings.ToLower(filepath.Ext(file.Name())) 182 | return ext == ".jpg" || ext == ".jpeg" || ext == ".png" 183 | } 184 | 185 | func er(msg interface{}) { 186 | fmt.Println("Error:", msg) 187 | os.Exit(1) 188 | } 189 | 190 | func Execute() { 191 | if err := rootCmd.Execute(); err != nil { 192 | er(err) 193 | } 194 | } 195 | 196 | func isValidSide(side string) bool { 197 | for _, s := range validSides { 198 | if s == side { 199 | return true 200 | } 201 | } 202 | return false 203 | } 204 | 205 | func safeConvertEquirectangularToCubeMap(edgeLen int, imgIn image.Image, sides []string) ([]*image.RGBA, error) { 206 | defer func() { 207 | if r := recover(); r != nil { 208 | fmt.Printf("Recovered in safeConvertEquirectangularToCubeMap: %v\n", r) 209 | } 210 | }() 211 | return conv.ConvertEquirectangularToCubeMap(edgeLen, imgIn, sides), nil 212 | } 213 | --------------------------------------------------------------------------------