diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 30b35ed..601a0d2 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -12,7 +12,11 @@ on: jobs: build: - runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -21,7 +25,8 @@ jobs: with: go-version: '1.25' - - name: Install system dependencies + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libvulkan-dev libx11-dev libx11-xcb-dev libxi-dev libxext-dev libegl-dev libgles-dev libxcursor-dev libxrandr-dev libxinerama-dev libxfixes-dev libgtk-3-dev pkg-config diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..920c22b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,41 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "GUI", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/cmd/gui" + }, + { + "name": "CLI - Church (fetch github.com/struffel/simple-deflicker-test-data into ../simple-deflicker-test-data first)", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/cmd/cli", + "args": [ + "-source", + "${workspaceFolder}/../simple-deflicker-test-data/real/church", + "-destination", + "${workspaceFolder}/../simple-deflicker-test-data/real/church/deflickered", + ] + }, + { + "name": "CLI - Clouds (fetch github.com/struffel/simple-deflicker-test-data into ../simple-deflicker-test-data first)", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/cmd/cli", + "args": [ + "-source", + "${workspaceFolder}/../simple-deflicker-test-data/real/clouds", + "-destination", + "${workspaceFolder}/../simple-deflicker-test-data/real/clouds/deflickered", + ] + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 4ca6885..c1e5c6e 100644 --- a/README.md +++ b/README.md @@ -7,39 +7,30 @@ A minimalist, easy to use tool for deflickering image sequences such as timelaps Timelapse flickering can occur if one or more settings of the camera have been left on "auto" which causes it to randomly switch between two settings (for example shutter speeds). ## How to use this software -* Download the latest version from the [releases page](https://github.com/StruffelProductions/simple-deflicker/releases). Prebuilt binaries are provided for Windows, and macOS builds are CLI-only. -* Execute `simple-deflicker.exe` on Windows to use the GUI. Check the console for error messages. -![image](https://user-images.githubusercontent.com/31403260/115123359-f2bbe400-9fbc-11eb-84d7-29615c5030fb.png) +* Download the latest version from the [releases page](https://github.com/struffel/simple-deflicker/releases). Prebuilt binaries are provided only for Windows at this time. +* Execute `simple-deflicker.exe` to use the GUI or `simple-deflicker-cli.exe` to use the CLI version. ## CLI usage Simple Deflicker can run without the GUI by passing a source and destination directory: ```bash -simple-deflicker -source "/path/to/input" -destination "/path/to/output" +simple-deflicker-cli -source "/path/to/input" -destination "/path/to/output" ``` Optional flags: ```bash -simple-deflicker -source "/path/to/input" -destination "/path/to/output" -rollingAverage 15 -jpegCompression 95 -threads 8 +simple-deflicker-cli -source "/path/to/input" -destination "/path/to/output" -rollingAverage 15 -format png -jpegQuality 95 ``` -Build a CLI-only binary: +## Building from source +The GUI and CLI are separate binaries, built from `./cmd/gui` and `./cmd/cli` respectively: ```bash -go build -tags cli -o simple-deflicker +go build -o simple-deflicker ./cmd/gui +go build -o simple-deflicker-cli ./cmd/cli ``` -macOS builds are CLI-only: - -```bash -GOOS=darwin GOARCH=arm64 go build -o simple-deflicker-macos-arm64 -GOOS=darwin GOARCH=amd64 go build -o simple-deflicker-macos-amd64 -``` - -On platforms built with the `cli` tag, the GUI is disabled and `-source` plus `-destination` are required. - - ## Current limitations of the tool * Only JPG and PNG (8bit) are supported * JPGs will always be saved with a compression setting of 95 @@ -48,10 +39,3 @@ On platforms built with the `cli` tag, the GUI is disabled and `-source` plus `- ## How does the deflickering work? The current implementation uses a technique called [histogram matching](https://en.wikipedia.org/wiki/Histogram_matching). It basically creates a list of how often a certain brighness (or rather every individual brightness level) appears, creates a [rolling average](https://en.wikipedia.org/wiki/Moving_average) to allow for gradual brightness changes (for example in a day to night transition) and finally shifts the brightness to match the "smoothed out" brightness levels. - -## How is the software structured? (only important for developers, not for users) -The software uses several other packages: -* [Imaging](https://github.com/disintegration/imaging) for loading, saving and manipulating image files. -* [dialog](https://github.com/sqweek/dialog) for creating the dialog boxes and file selection windows. -* [uiprogress](https://github.com/gosuri/uiprogress) for creating the progress bars in the console. -* [nucular](https://github.com/aarzilli/nucular) for the GUI. diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..30a6d90 --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "fmt" + "os" + + "github.com/struffel/simple-deflicker/internal/deflicker" + "github.com/struffel/simple-deflicker/internal/progress" +) + +func main() { + + // Read CLI parameters. + settings := deflicker.NewSettingsFromArgs() + + // In CLI mode, check the settings immediately + validationErrors := settings.Validate() + if len(validationErrors) > 0 { + for _, err := range validationErrors { + fmt.Println(err) + } + os.Exit(1) + } + + // Run the actual deflickering + deflickeringError := deflicker.Run(settings, &progress.ConsoleUpdater{}) + if deflickeringError != nil { + fmt.Println("An error occured:") + fmt.Println(deflickeringError) + os.Exit(1) + } + os.Exit(0) + +} diff --git a/cmd/gui/main.go b/cmd/gui/main.go new file mode 100644 index 0000000..457c3fd --- /dev/null +++ b/cmd/gui/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "fmt" + "os" + + "github.com/struffel/simple-deflicker/internal/ui" +) + +func main() { + if err := ui.StartGUI(); err != nil { + fmt.Println(err) + os.Exit(1) + } + os.Exit(0) +} diff --git a/config.go b/config.go deleted file mode 100644 index 1912282..0000000 --- a/config.go +++ /dev/null @@ -1,56 +0,0 @@ -package main - -import ( - "errors" - "flag" - "runtime" -) - -type configuration struct { - sourceDirectory string - destinationDirectory string - rollingAverage int - jpegCompression int - threads int -} - -var config configuration - -func collectConfigInformation() configuration { - var config configuration - flag.StringVar(&config.sourceDirectory, "source", "", "Directory with the images to process.") - flag.StringVar(&config.destinationDirectory, "destination", "", "Directory to put the processed images in.") - flag.IntVar(&config.rollingAverage, "rollingAverage", 15, "Number of frames to use for rolling average. 0 disables it.") - flag.IntVar(&config.jpegCompression, "jpegCompression", 95, "Level of JPEG compression. Must be between 1 - 100. Default is 95.") - flag.IntVar(&config.threads, "threads", runtime.NumCPU(), "Number of threads to use. Default is the detected number of cores.") - flag.Parse() - return config -} -func validateConfigInformation() error { - description := "" - //Test for illegal inputs - if config.jpegCompression < 1 || config.jpegCompression > 100 { - description += "Invalid JPEG compression setting. Value must be between 1 and 100 (inclusive).\n" - } - if config.threads < 1 { - description += "Invalid number of threads. There must be at least one thread.\n" - } - if config.rollingAverage < 0 { - description += "Invalid rolling average. Value must be equal to or greater than 1.\n" - } - if config.sourceDirectory == "" { - description += "No source directory specified.\n" - } else if !testForDirectory(config.sourceDirectory) { - description += "The source directory could not be found.\n" - } - if config.destinationDirectory == "" { - description += "No destination directory specified.\n" - } else if !testForDirectory(config.destinationDirectory) { - description += "The destination directory could not be found.\n" - } - if description != "" { - return errors.New(description) - } else { - return nil - } -} diff --git a/files.go b/files.go deleted file mode 100644 index b7a04dc..0000000 --- a/files.go +++ /dev/null @@ -1,39 +0,0 @@ -package main - -import ( - "errors" - "io/ioutil" - "os" - "path/filepath" - "strings" -) - -func readDirectory(currentDirectory string, targetDirectory string) ([]picture, error) { - var pictures []picture - //Get list of files - files, err := ioutil.ReadDir(currentDirectory) - if err != nil { - return pictures, err - } - //Prepare slice of pictures - for _, file := range files { - var fullSourcePath = filepath.Join(currentDirectory, file.Name()) - var fullTargetPath = filepath.Join(targetDirectory, file.Name()) - var extension = strings.ToLower(filepath.Ext(file.Name())) - var temp rgbHistogram - if extension == ".jpg" || extension == ".png" { - pictures = append(pictures, picture{fullSourcePath, fullTargetPath, temp, temp}) - } - } - if len(pictures) < 1 { - return pictures, errors.New("the source directory does not contain any compatible images (JPG or PNG)") - } - return pictures, nil -} - -func testForDirectory(directory string) bool { - if _, err := os.Stat(directory); os.IsNotExist(err) { - return false - } - return true -} diff --git a/go.mod b/go.mod index 5c3e7d2..a64ad96 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,24 @@ module github.com/struffel/simple-deflicker -go 1.15 +go 1.25.0 require ( - github.com/aarzilli/nucular v0.0.0-20210203155122-9112adf75b4f + gioui.org v0.10.1 github.com/disintegration/imaging v1.6.2 - github.com/gosuri/uilive v0.0.4 // indirect - github.com/gosuri/uiprogress v0.0.1 - github.com/inancgumus/screen v0.0.0-20190314163918-06e984b86ed3 - github.com/mattn/go-isatty v0.0.12 // indirect - github.com/sqweek/dialog v0.0.0-20200911184034-8a3d98e8211d - golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad // indirect - golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect - golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf // indirect + github.com/ncruces/zenity v0.10.14 + golang.org/x/sync v0.22.0 +) + +require ( + gioui.org/shader v1.0.8 // indirect + github.com/akavel/rsrc v0.10.2 // indirect + github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f // indirect + github.com/go-text/typesetting v0.3.4 // indirect + github.com/josephspurrier/goversioninfo v1.4.1 // indirect + github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 // indirect + golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/image v0.26.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect ) diff --git a/go.sum b/go.sum index da39ef1..907160a 100644 --- a/go.sum +++ b/go.sum @@ -1,66 +1,51 @@ -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9 h1:VpgP7xuJadIUuKccphEpTJnWhS2jkQyMt6Y7pJCD7fY= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -gioui.org v0.0.0-20210106084211-c030065af7bc h1:0jo2QznfZl35y/Bzqt915yUT1ruaTjmC24iJM/4vA94= -gioui.org v0.0.0-20210106084211-c030065af7bc/go.mod h1:Y+uS7hHMvku1Q+ooaoq6fYD5B2LGoT8JtFgvmYmRzTw= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 h1:1BDTz0u9nC3//pOCMdNH+CiXJVYJh5UQNCOBG7jbELc= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf h1:FPsprx82rdrX2jiKyS17BH6IrTmUBYqZa/CXT4uvb+I= -github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I= -github.com/aarzilli/nucular v0.0.0-20210203155122-9112adf75b4f h1:lBc+GomHbMn3kT04rRj0P2L1ky5RzqmiiAhERGp2848= -github.com/aarzilli/nucular v0.0.0-20210203155122-9112adf75b4f/go.mod h1:QBibXlOFoxEpCak/TJENqnJo8+Pgp1VS06V2Rf/gSQQ= +eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY= +eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= +gioui.org v0.10.1 h1:Dvp6iDk9RKuZk19jxhOmb4p673CLVvb656LyMxQ+uO0= +gioui.org v0.10.1/go.mod h1:MZJZsdEPkTBzChdqeE8CiiQhreUQBj43qusDxQNDf7k= +gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= +gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= +gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= +github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw= +github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f h1:OGqDDftRTwrvUoL6pOG7rYTmWsTCvyEWFsMjg+HcOaA= +github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f/go.mod h1:Dv9D0NUlAsaQcGQZa5kc5mqR9ua72SmA8VXi4cd+cBw= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1 h1:QbL/5oDUmRBzO9/Z7Seo6zf912W/a6Sr4Eu0G/3Jho0= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72 h1:b+9H1GAsx5RsjvDFLoS5zkNBzIQMuVKUYQDmxU3N5XE= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/golang/freetype v0.0.0-20161208064710-d9be45aaf745 h1:0d9whnMsm0iklqvoBXNEgHPt8pkXdfDplBAswA/F8YA= -github.com/golang/freetype v0.0.0-20161208064710-d9be45aaf745/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= -github.com/gosuri/uilive v0.0.4 h1:hUEBpQDj8D8jXgtCdBu7sWsy5sbW/5GhuO8KBwJ2jyY= -github.com/gosuri/uilive v0.0.4/go.mod h1:V/epo5LjjlDE5RJUcqx8dbw+zc93y5Ya3yg8tfZ74VI= -github.com/gosuri/uiprogress v0.0.1 h1:0kpv/XY/qTmFWl/SkaJykZXrBBzwwadmW8fRb7RJSxw= -github.com/gosuri/uiprogress v0.0.1/go.mod h1:C1RTYn4Sc7iEyf6j8ft5dyoZ4212h8G1ol9QQluh5+0= -github.com/hashicorp/golang-lru v0.0.0-20160813221303-0a025b7e63ad h1:eMxs9EL0PvIGS9TTtxg4R+JxuPGav82J8rA+GFnY7po= -github.com/hashicorp/golang-lru v0.0.0-20160813221303-0a025b7e63ad/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/inancgumus/screen v0.0.0-20190314163918-06e984b86ed3 h1:fO9A67/izFYFYky7l1pDP5Dr0BTCRkaQJUG6Jm5ehsk= -github.com/inancgumus/screen v0.0.0-20190314163918-06e984b86ed3/go.mod h1:Ey4uAp+LvIl+s5jRbOHLcZpUDnkjLBROl15fZLwPlTM= -github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/sqweek/dialog v0.0.0-20200911184034-8a3d98e8211d h1:Chay1rwJnXxI27H+pzu7P81BKf647un9GOoRPTdXN18= -github.com/sqweek/dialog v0.0.0-20200911184034-8a3d98e8211d/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY= -golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3/go.mod h1:NOZ3BPKG0ec/BKJQgnvsSFpcKLM5xXVWnvZS97DWHgE= -golang.org/x/exp v0.0.0-20191224044220-1fea468a75e9 h1:HLuLY2KniBsHW28uXd1i2UZKjifeJUy//P/wTK6AJwI= -golang.org/x/exp v0.0.0-20191224044220-1fea468a75e9/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU= +github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY= +github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos= +github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= +github.com/josephspurrier/goversioninfo v1.4.1 h1:5LvrkP+n0tg91J9yTkoVnt/QgNnrI1t4uSsWjIonrqY= +github.com/josephspurrier/goversioninfo v1.4.1/go.mod h1:JWzv5rKQr+MmW+LvM412ToT/IkYDZjaclF2pKDss8IY= +github.com/ncruces/zenity v0.10.14 h1:OBFl7qfXcvsdo1NUEGxTlZvAakgWMqz9nG38TuiaGLI= +github.com/ncruces/zenity v0.10.14/go.mod h1:ZBW7uVe/Di3IcRYH0Br8X59pi+O6EPnNIOU66YHpOO4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 h1:GranzK4hv1/pqTIhMTXt2X8MmMOuH3hMeUR0o9SP5yc= +github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844/go.mod h1:T1TLSfyWVBRXVGzWd0o9BI4kfoO9InEgfQe4NV3mLz8= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80= +golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20200618115811-c13761719519 h1:1e2ufUJNM3lCHEY5jIgac/7UTjd6cgJNdatjPdFWf34= -golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 h1:4+4C/Iv2U4fMZBiMCc98MG1In4gJY5YRhtpDNeDeHWs= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= -golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlpnxz5GSOhOmtHo3iPU6M= -golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY= +golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/gui.go b/gui.go deleted file mode 100644 index 91198b6..0000000 --- a/gui.go +++ /dev/null @@ -1,77 +0,0 @@ -//go:build !darwin && !cli -// +build !darwin,!cli - -package main - -import ( - "fmt" - "image" - "path/filepath" - - "github.com/aarzilli/nucular/label" - "github.com/aarzilli/nucular/style" - - "github.com/aarzilli/nucular" - "github.com/sqweek/dialog" -) - -var window = nucular.NewMasterWindowSize(0, "Simple Deflicker", image.Point{450, 450}, windowUpdateFunction) -var sourceField nucular.TextEditor -var destinationField nucular.TextEditor - -func startGUI() error { - initalizeWindow() - window.Main() - return nil -} - -func initalizeWindow() { - window.SetStyle(style.FromTheme(style.DarkTheme, 1.0)) - sourceField.Flags = nucular.EditSelectable | nucular.EditClipboard | nucular.EditSigEnter | nucular.EditIbeamCursor - sourceField.SingleLine = true - sourceField.Buffer = []rune(config.sourceDirectory) - - destinationField.Flags = nucular.EditSelectable | nucular.EditClipboard | nucular.EditSigEnter | nucular.EditIbeamCursor - destinationField.SingleLine = true - destinationField.Buffer = []rune(config.destinationDirectory) -} - -func windowUpdateFunction(w *nucular.Window) { - //Source Directory - w.Row(25).Dynamic(1) - w.Label("Source Directory", "LB") - sourceField.Edit(w) - sourceField.Buffer = []rune(filepath.ToSlash(string(sourceField.Buffer))) - w.Row(25).Ratio(0.333) - if w.ButtonText("Browse") { - directory, _ := dialog.Directory().Title("Select a source directory.").Browse() - sourceField.Buffer = []rune(filepath.ToSlash(directory)) - } - w.Row(25).Dynamic(1) - //Destination Directory - w.Label("Destination Directory", "LB") - destinationField.Edit(w) - destinationField.Buffer = []rune(filepath.ToSlash(string(destinationField.Buffer))) - w.Row(25).Ratio(0.333) - if w.ButtonText("Browse") { - directory, _ := dialog.Directory().Title("Select a destination directory.").Browse() - destinationField.Buffer = []rune(filepath.ToSlash(directory)) - } - w.Row(25).Dynamic(1) - w.Label("Advanced settings", "LB") - w.PropertyInt("Rolling average", 0, &config.rollingAverage, 100, 1, 1) - w.PropertyInt("JPEG quality", 1, &config.jpegCompression, 100, 1, 1) - w.PropertyInt("Threads", 1, &config.threads, 128, 1, 1) - w.Row(35).Dynamic(1) - if w.Button(label.T("Start"), false) { - w.Label("TEST", "LB") - config.sourceDirectory = string(sourceField.Buffer) - config.destinationDirectory = string(destinationField.Buffer) - deflickeringError := runDeflickering() - if deflickeringError != nil { - clear() - fmt.Println("An error occurred:") - fmt.Println(deflickeringError) - } - } -} diff --git a/gui_cli.go b/gui_cli.go deleted file mode 100644 index 6dd39ff..0000000 --- a/gui_cli.go +++ /dev/null @@ -1,10 +0,0 @@ -//go:build darwin || cli -// +build darwin cli - -package main - -import "errors" - -func startGUI() error { - return errors.New("GUI mode is not available in this build. Use -source and -destination to run in CLI mode") -} diff --git a/histogram.go b/histogram.go deleted file mode 100644 index 3200044..0000000 --- a/histogram.go +++ /dev/null @@ -1,141 +0,0 @@ -package main - -import ( - "image" - "image/color" - "math" - - "github.com/disintegration/imaging" -) - -type lut [256]uint8 -type rgbLut struct { - r lut - g lut - b lut -} -type histogram [256]uint32 -type rgbHistogram struct { - r histogram - g histogram - b histogram -} - -const ( - lutCorrectionStrength = 0.8 - lutSmoothingRadius = 5 -) - -func generateRgbHistogramFromImage(input image.Image) rgbHistogram { - var rgbHistogram rgbHistogram - for y := input.Bounds().Min.Y; y < input.Bounds().Max.Y; y++ { - for x := input.Bounds().Min.X; x < input.Bounds().Max.X; x++ { - r, g, b, _ := input.At(x, y).RGBA() - r = r >> 8 - g = g >> 8 - b = b >> 8 - rgbHistogram.r[r]++ - rgbHistogram.g[g]++ - rgbHistogram.b[b]++ - } - } - return rgbHistogram -} - -func convertToCumulativeRgbHistogram(input rgbHistogram) rgbHistogram { - var targetRgbHistogram rgbHistogram - targetRgbHistogram.r[0] = input.r[0] - targetRgbHistogram.g[0] = input.g[0] - targetRgbHistogram.b[0] = input.b[0] - for i := 1; i < 256; i++ { - targetRgbHistogram.r[i] = targetRgbHistogram.r[i-1] + input.r[i] - targetRgbHistogram.g[i] = targetRgbHistogram.g[i-1] + input.g[i] - targetRgbHistogram.b[i] = targetRgbHistogram.b[i-1] + input.b[i] - } - return targetRgbHistogram -} - -func generateRgbLutFromRgbHistograms(current rgbHistogram, target rgbHistogram) rgbLut { - currentCumulativeRgbHistogram := convertToCumulativeRgbHistogram(current) - targetCumulativeRgbHistogram := convertToCumulativeRgbHistogram(target) - var ratio [3]float64 - ratio[0] = float64(currentCumulativeRgbHistogram.r[255]) / float64(targetCumulativeRgbHistogram.r[255]) - ratio[1] = float64(currentCumulativeRgbHistogram.g[255]) / float64(targetCumulativeRgbHistogram.g[255]) - ratio[2] = float64(currentCumulativeRgbHistogram.b[255]) / float64(targetCumulativeRgbHistogram.b[255]) - for i := 0; i < 256; i++ { - targetCumulativeRgbHistogram.r[i] = uint32(0.5 + float64(targetCumulativeRgbHistogram.r[i])*ratio[0]) - targetCumulativeRgbHistogram.g[i] = uint32(0.5 + float64(targetCumulativeRgbHistogram.g[i])*ratio[1]) - targetCumulativeRgbHistogram.b[i] = uint32(0.5 + float64(targetCumulativeRgbHistogram.b[i])*ratio[2]) - } - - //Generate LUT - var lut rgbLut - var p [3]uint8 - for i := 0; i < 256; i++ { - for targetCumulativeRgbHistogram.r[p[0]] < currentCumulativeRgbHistogram.r[i] { - p[0]++ - } - for targetCumulativeRgbHistogram.g[p[1]] < currentCumulativeRgbHistogram.g[i] { - p[1]++ - } - for targetCumulativeRgbHistogram.b[p[2]] < currentCumulativeRgbHistogram.b[i] { - p[2]++ - } - lut.r[i] = p[0] - lut.g[i] = p[1] - lut.b[i] = p[2] - } - lut.r = regularizeLut(lut.r) - lut.g = regularizeLut(lut.g) - lut.b = regularizeLut(lut.b) - return lut -} - -func applyRgbLutToImage(input image.Image, lut rgbLut) image.Image { - result := imaging.AdjustFunc(input, func(c color.NRGBA) color.NRGBA { - c.R = uint8(lut.r[c.R]) - c.G = uint8(lut.g[c.G]) - c.B = uint8(lut.b[c.B]) - return c - }) - return result -} - -func regularizeLut(input lut) lut { - var smoothed lut - for i := 0; i < 256; i++ { - sum := 0 - count := 0 - for j := i - lutSmoothingRadius; j <= i+lutSmoothingRadius; j++ { - if j < 0 || j > 255 { - continue - } - sum += int(input[j]) - count++ - } - smoothed[i] = uint8(sum / count) - } - for i := 1; i < 256; i++ { - if smoothed[i] < smoothed[i-1] { - smoothed[i] = smoothed[i-1] - } - } - - var result lut - for i := 0; i < 256; i++ { - corrected := float64(smoothed[i])*lutCorrectionStrength + float64(i)*(1-lutCorrectionStrength) - result[i] = clampUint8(corrected) - } - return result -} - -func clampUint8(value float64) uint8 { - value = math.Round(value) - if value < 0 { - return 0 - } - if value > 255 { - return 255 - } - return uint8(value) -} diff --git a/internal/deflicker/deflicker.go b/internal/deflicker/deflicker.go new file mode 100644 index 0000000..bf101d5 --- /dev/null +++ b/internal/deflicker/deflicker.go @@ -0,0 +1,181 @@ +package deflicker + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + + "github.com/struffel/simple-deflicker/internal/progress" + "golang.org/x/sync/errgroup" +) + +type pictureInfo struct { + Name string + OriginalRgbHistogram rgbHistogram + DesiredRgbHistogram rgbHistogram +} + +func Run(settings Settings, updater progress.Updater) error { + + // Validate config information + settingsErrors := settings.Validate() + if len(settingsErrors) > 0 { + return fmt.Errorf("Settings validation failed: %v", settingsErrors) + } + + updater.Start() + + // Read the entire source directory and create Picture structs with histograms + pictures, err := getSourcePictureInfo(settings.SourceDirectory, updater) + if err != nil { + return err + } + + err = fillDesiredHistograms(&pictures, updater, settings.RollingAverage) + if err != nil { + return err + } + + adjustImages(&pictures, settings, updater) + + updater.Finish() + return nil +} + +func adjustImages(pictures *[]pictureInfo, settings Settings, updater progress.Updater) error { + total := len(*pictures) + var completed atomic.Int32 + + var g errgroup.Group + g.SetLimit(runtime.NumCPU()) + + for i := range *pictures { + g.Go(func() error { + sourcePath := filepath.Join(settings.SourceDirectory, (*pictures)[i].Name) + + destinationExtension := settings.OutFormat.Extension() + destinationFileName := strings.TrimSuffix((*pictures)[i].Name, filepath.Ext((*pictures)[i].Name)) + destinationExtension + destinationPath := filepath.Join(settings.DestinationDirectory, destinationFileName) + + sourceImage, err := readImage(sourcePath) + if err != nil { + return err + } + + destinationLut := generateRgbLutFromRgbHistograms((*pictures)[i].OriginalRgbHistogram, (*pictures)[i].DesiredRgbHistogram) + destinationImage := applyRgbLutToImage(sourceImage, destinationLut) + + if err := saveImage(destinationImage, destinationPath, settings.OutFormat, settings.JpegQuality); err != nil { + return err + } + + updater.Increment((*pictures)[i].Name, "Adjusting image", int(completed.Add(1)), total) + return nil + }) + } + return g.Wait() +} + +func fillDesiredHistograms(pictures *[]pictureInfo, updater progress.Updater, rollingAverage int) error { + + if rollingAverage < 1 { + // Simply calculate the global average histogram + var averageRgbHistogram rgbHistogram + for i := range *pictures { + for j := 0; j < 256; j++ { + averageRgbHistogram.R[j] += (*pictures)[i].OriginalRgbHistogram.R[j] + averageRgbHistogram.G[j] += (*pictures)[i].OriginalRgbHistogram.G[j] + averageRgbHistogram.B[j] += (*pictures)[i].OriginalRgbHistogram.B[j] + } + //updater.Increment((*pictures)[i].Name, "Calculating average histogram", i+1, len(*pictures)) + } + for i := 0; i < 256; i++ { + averageRgbHistogram.R[i] /= uint32(len(*pictures)) + averageRgbHistogram.G[i] /= uint32(len(*pictures)) + averageRgbHistogram.B[i] /= uint32(len(*pictures)) + } + for i := range *pictures { + (*pictures)[i].DesiredRgbHistogram = averageRgbHistogram + } + } else { + // Calculate the rolling average histogram for each image + for i := range *pictures { + var averageRgbHistogram rgbHistogram + var start = i - rollingAverage + if start < 0 { + start = 0 + } + var end = i + rollingAverage + if end > len(*pictures)-1 { + end = len(*pictures) - 1 + } + for j := start; j <= end; j++ { + for k := 0; k < 256; k++ { + averageRgbHistogram.R[k] += (*pictures)[j].OriginalRgbHistogram.R[k] + averageRgbHistogram.G[k] += (*pictures)[j].OriginalRgbHistogram.G[k] + averageRgbHistogram.B[k] += (*pictures)[j].OriginalRgbHistogram.B[k] + } + } + for k := 0; k < 256; k++ { + averageRgbHistogram.R[k] /= uint32(end - start + 1) + averageRgbHistogram.G[k] /= uint32(end - start + 1) + averageRgbHistogram.B[k] /= uint32(end - start + 1) + } + (*pictures)[i].DesiredRgbHistogram = averageRgbHistogram + //updater.Increment((*pictures)[i].Name, "Calculating rolling average histogram", i+1, len(*pictures)) + } + } + + return nil +} + +func getSourcePictureInfo(directory string, updater progress.Updater) ([]pictureInfo, error) { + + // Get raw list of files + files, err := os.ReadDir(directory) + if err != nil { + return nil, err + } + + // Filter down to the compatible image file names + var imageNames []string + for _, file := range files { + extension := strings.ToLower(filepath.Ext(file.Name())) + if extension == ".jpg" || extension == ".png" { + imageNames = append(imageNames, file.Name()) + } + } + if len(imageNames) < 1 { + return nil, errors.New("the source directory does not contain any compatible images (JPG or PNG)") + } + + totalFiles := len(imageNames) + pictures := make([]pictureInfo, totalFiles) + var completed atomic.Int32 + + var g errgroup.Group + g.SetLimit(runtime.NumCPU()) + + // Calculate histograms concurrently + for index, name := range imageNames { + g.Go(func() error { + image, err := readImage(filepath.Join(directory, name)) + if err != nil { + return err + } + histogram := generateRgbHistogramFromImage(image) + pictures[index] = pictureInfo{Name: name, OriginalRgbHistogram: histogram, DesiredRgbHistogram: rgbHistogram{}} + + updater.Increment(name, "Calculating histogram", int(completed.Add(1)), totalFiles) + return nil + }) + } + if err := g.Wait(); err != nil { + return nil, err + } + return pictures, nil +} diff --git a/internal/deflicker/histogram.go b/internal/deflicker/histogram.go new file mode 100644 index 0000000..dc07fa7 --- /dev/null +++ b/internal/deflicker/histogram.go @@ -0,0 +1,150 @@ +package deflicker + +import ( + "image" + "image/color" + "math" + + "github.com/disintegration/imaging" +) + +type lut [256]uint8 +type rgbLut struct { + R lut + G lut + B lut +} +type histogram [256]uint32 +type rgbHistogram struct { + R histogram + G histogram + B histogram +} + +const ( + lutCorrectionStrength = 0.8 + lutSmoothingRadius = 5 +) + +// generateRgbHistogramFromImage generates an RGB histogram from the given image. +func generateRgbHistogramFromImage(input image.Image) rgbHistogram { + var rgbHistogram rgbHistogram + for y := input.Bounds().Min.Y; y < input.Bounds().Max.Y; y++ { + for x := input.Bounds().Min.X; x < input.Bounds().Max.X; x++ { + r, g, b, _ := input.At(x, y).RGBA() + r = r >> 8 + g = g >> 8 + b = b >> 8 + rgbHistogram.R[r]++ + rgbHistogram.G[g]++ + rgbHistogram.B[b]++ + } + } + return rgbHistogram +} + +// convertToCumulativeRgbHistogram converts a given RGB histogram into a cumulative histogram. +func convertToCumulativeRgbHistogram(input rgbHistogram) rgbHistogram { + var targetRgbHistogram rgbHistogram + targetRgbHistogram.R[0] = input.R[0] + targetRgbHistogram.G[0] = input.G[0] + targetRgbHistogram.B[0] = input.B[0] + for i := 1; i < 256; i++ { + targetRgbHistogram.R[i] = targetRgbHistogram.R[i-1] + input.R[i] + targetRgbHistogram.G[i] = targetRgbHistogram.G[i-1] + input.G[i] + targetRgbHistogram.B[i] = targetRgbHistogram.B[i-1] + input.B[i] + } + return targetRgbHistogram +} + +// generateRgbLutFromRgbHistograms generates a lookup table (LUT) for each color channel (R, G, B) based on the current and target RGB histograms. +// The LUT is used to map pixel values from the current image to the target image, effectively adjusting the colors to match the desired histogram. +func generateRgbLutFromRgbHistograms(current rgbHistogram, target rgbHistogram) rgbLut { + currentCumulativeRgbHistogram := convertToCumulativeRgbHistogram(current) + targetCumulativeRgbHistogram := convertToCumulativeRgbHistogram(target) + + var ratio [3]float64 + ratio[0] = float64(currentCumulativeRgbHistogram.R[255]) / float64(targetCumulativeRgbHistogram.R[255]) + ratio[1] = float64(currentCumulativeRgbHistogram.G[255]) / float64(targetCumulativeRgbHistogram.G[255]) + ratio[2] = float64(currentCumulativeRgbHistogram.B[255]) / float64(targetCumulativeRgbHistogram.B[255]) + for i := 0; i < 256; i++ { + targetCumulativeRgbHistogram.R[i] = uint32(0.5 + float64(targetCumulativeRgbHistogram.R[i])*ratio[0]) + targetCumulativeRgbHistogram.G[i] = uint32(0.5 + float64(targetCumulativeRgbHistogram.G[i])*ratio[1]) + targetCumulativeRgbHistogram.B[i] = uint32(0.5 + float64(targetCumulativeRgbHistogram.B[i])*ratio[2]) + } + + //Generate LUT + var lut rgbLut + var p [3]uint8 + for i := 0; i < 256; i++ { + for targetCumulativeRgbHistogram.R[p[0]] < currentCumulativeRgbHistogram.R[i] { + p[0]++ + } + for targetCumulativeRgbHistogram.G[p[1]] < currentCumulativeRgbHistogram.G[i] { + p[1]++ + } + for targetCumulativeRgbHistogram.B[p[2]] < currentCumulativeRgbHistogram.B[i] { + p[2]++ + } + lut.R[i] = p[0] + lut.G[i] = p[1] + lut.B[i] = p[2] + } + lut.R = regularizeLut(lut.R) + lut.G = regularizeLut(lut.G) + lut.B = regularizeLut(lut.B) + return lut +} + +// applyRgbLutToImage applies the given RGB lookup table (LUT) to the input image, adjusting its pixel values according to the LUT. +func applyRgbLutToImage(input image.Image, lut rgbLut) image.Image { + result := imaging.AdjustFunc(input, func(c color.NRGBA) color.NRGBA { + c.R = uint8(lut.R[c.R]) + c.G = uint8(lut.G[c.G]) + c.B = uint8(lut.B[c.B]) + return c + }) + return result +} + +// regularizeLut smooths and corrects the given lookup table (LUT) to ensure that it is monotonically increasing and applies a correction strength to the values. +// This helps in preventing abrupt changes in pixel values when applying the LUT to an image. +func regularizeLut(input lut) lut { + var smoothed lut + for i := 0; i < 256; i++ { + sum := 0 + count := 0 + for j := i - lutSmoothingRadius; j <= i+lutSmoothingRadius; j++ { + if j < 0 || j > 255 { + continue + } + sum += int(input[j]) + count++ + } + smoothed[i] = uint8(sum / count) + } + for i := 1; i < 256; i++ { + if smoothed[i] < smoothed[i-1] { + smoothed[i] = smoothed[i-1] + } + } + + var result lut + for i := 0; i < 256; i++ { + corrected := float64(smoothed[i])*lutCorrectionStrength + float64(i)*(1-lutCorrectionStrength) + result[i] = clampUint8(corrected) + } + return result +} + +// clampUint8 clamps a float64 value to the range of 0 to 255 and returns it as a uint8. +func clampUint8(value float64) uint8 { + value = math.Round(value) + if value < 0 { + return 0 + } + if value > 255 { + return 255 + } + return uint8(value) +} diff --git a/internal/deflicker/io.go b/internal/deflicker/io.go new file mode 100644 index 0000000..4736c51 --- /dev/null +++ b/internal/deflicker/io.go @@ -0,0 +1,67 @@ +package deflicker + +import ( + "errors" + "image" + "image/jpeg" + "image/png" + "os" + "path/filepath" + "strings" +) + +func directoryExists(path string) bool { + stat, err := os.Stat(path) + if err == nil && stat.IsDir() { + return true + } + return false +} + +func listImagesInDirectory(path string) ([]string, error) { + files, err := os.ReadDir(path) + if err != nil { + return nil, err + } + var images []string + for _, file := range files { + extension := strings.ToLower(filepath.Ext(file.Name())) + if extension == ".jpg" || extension == ".png" { + images = append(images, filepath.Join(path, file.Name())) + } + } + return images, nil +} + +func readImage(path string) (image.Image, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + img, _, err := image.Decode(file) + if err != nil { + return nil, err + } + return img, nil +} + +func saveImage(img image.Image, path string, format OutputFormat, jpegQuality int) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + switch format { + case FormatJpeg: + err = jpeg.Encode(file, img, &jpeg.Options{Quality: jpegQuality}) + case FormatPng: + err = png.Encode(file, img) + default: + return errors.New("unsupported output format") + } + + return nil +} diff --git a/internal/deflicker/settings.go b/internal/deflicker/settings.go new file mode 100644 index 0000000..a1be846 --- /dev/null +++ b/internal/deflicker/settings.go @@ -0,0 +1,83 @@ +package deflicker + +import ( + "flag" + "fmt" +) + +type OutputFormat string + +const ( + FormatJpeg OutputFormat = "jpeg" + FormatPng OutputFormat = "png" +) + +func (f OutputFormat) Extension() string { + switch f { + case FormatJpeg: + return ".jpg" + case FormatPng: + return ".png" + default: + panic("Unknown output format") + } +} + +type Settings struct { + SourceDirectory string + DestinationDirectory string + RollingAverage int + OutFormat OutputFormat + JpegQuality int +} + +// DefaultSettings returns the settings the GUI is pre-populated and that the CLI uses if no arguments are provided. +func DefaultSettings() Settings { + return Settings{ + SourceDirectory: "", + DestinationDirectory: "", + RollingAverage: 15, + OutFormat: FormatPng, + JpegQuality: 95, + } +} + +func NewSettingsFromArgs() Settings { + var defaultSettings = DefaultSettings() + var settings Settings + var tmpFormat string + flag.StringVar(&settings.SourceDirectory, "source", defaultSettings.SourceDirectory, "Directory with the images to process.") + flag.StringVar(&settings.DestinationDirectory, "destination", defaultSettings.DestinationDirectory, "Directory to put the processed images in.") + flag.IntVar(&settings.RollingAverage, "rollingAverage", defaultSettings.RollingAverage, "Number of frames to use for rolling average. 0 disables it.") + flag.StringVar(&tmpFormat, "format", string(defaultSettings.OutFormat), "Output format. Options are jpeg png.") + flag.IntVar(&settings.JpegQuality, "jpegQuality", defaultSettings.JpegQuality, "Level of JPEG compression. Must be between 1 - 100.") + flag.Parse() + settings.OutFormat = OutputFormat(tmpFormat) + return settings +} + +func (s *Settings) Validate() []error { + errors := []error{} + + if s.JpegQuality < 1 || s.JpegQuality > 100 { + errors = append(errors, fmt.Errorf("Invalid JPEG compression setting. Value must be between 1 and 100 (inclusive).")) + } + if s.RollingAverage < 0 { + errors = append(errors, fmt.Errorf("Invalid rolling average. Value must be equal to or greater than 0, with 0 disabling it.")) + } + if s.OutFormat != FormatJpeg && s.OutFormat != FormatPng { + errors = append(errors, fmt.Errorf("Invalid output format. Options are jpeg png.")) + } + + if s.SourceDirectory == "" { + errors = append(errors, fmt.Errorf("No source directory specified.")) + } else if !directoryExists(s.SourceDirectory) { + errors = append(errors, fmt.Errorf("The source directory could not be found.")) + } + if s.DestinationDirectory == "" { + errors = append(errors, fmt.Errorf("No destination directory specified.")) + } else if !directoryExists(s.DestinationDirectory) { + errors = append(errors, fmt.Errorf("The destination directory could not be found.")) + } + return errors +} diff --git a/internal/progress/progress.go b/internal/progress/progress.go new file mode 100644 index 0000000..2584618 --- /dev/null +++ b/internal/progress/progress.go @@ -0,0 +1,24 @@ +package progress + +import "fmt" + +type Updater interface { + Start() + Increment(msg string, phase string, completed int, ofTotal int) + Finish() +} + +// Default implementation for printing to the console. +type ConsoleUpdater struct{} + +func (c *ConsoleUpdater) Start() { + fmt.Println("Processing started...") +} + +func (c *ConsoleUpdater) Increment(msg string, phase string, completed int, ofTotal int) { + fmt.Printf("%s: %s (%d/%d)\n", phase, msg, completed, ofTotal) +} + +func (c *ConsoleUpdater) Finish() { + fmt.Println("Processing finished.") +} diff --git a/internal/ui/gui.go b/internal/ui/gui.go new file mode 100644 index 0000000..035ab53 --- /dev/null +++ b/internal/ui/gui.go @@ -0,0 +1,170 @@ +package ui + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + + "gioui.org/app" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/unit" + "gioui.org/widget/material" + + "github.com/ncruces/zenity" + + "github.com/struffel/simple-deflicker/internal/deflicker" +) + +func StartGUI() error { + go func() { + w := new(app.Window) + w.Option(app.Title("Simple Deflicker"), app.Size(unit.Dp(400), unit.Dp(500))) + if err := runWindow(w); err != nil { + fmt.Println(err) + os.Exit(1) + } + os.Exit(0) + }() + app.Main() + return nil +} + +func runWindow(w *app.Window) error { + theme := material.NewTheme() + state := newUiState(deflicker.DefaultSettings()) + + var ops op.Ops + for { + switch e := w.Event().(type) { + case app.DestroyEvent: + return e.Err + case app.FrameEvent: + gtx := app.NewContext(&ops, e) + receiveGuiResults(state) + handleGuiEvents(gtx, w, state) + layoutGui(gtx, theme, state) + e.Frame(gtx.Ops) + } + } +} + +// receiveGuiResults applies any results delivered by background goroutines +// (directory pickers, deflickering) to the widget state. +func receiveGuiResults(state *uiState) { + select { + case dir := <-state.sourceResult: + state.sourceEditor.SetText(filepath.ToSlash(dir)) + default: + } + select { + case dir := <-state.destinationResult: + state.destinationEditor.SetText(filepath.ToSlash(dir)) + default: + } + select { + case err := <-state.deflickerResult: + state.processing = false + state.setProgress(0, "") + if err != nil { + state.statusText = "An error occurred: " + err.Error() + go zenity.Error(err.Error(), zenity.Title("Simple Deflicker - Error")) + } else { + state.statusText = "Saved pictures into " + state.Settings.DestinationDirectory + go zenity.Info(state.statusText, zenity.Title("Simple Deflicker")) + } + default: + } +} + +func handleGuiEvents(gtx layout.Context, w *app.Window, state *uiState) { + if state.processing { + return + } + + state.formatEnum.Update(gtx) + state.Settings.OutFormat = deflicker.OutputFormat(state.formatEnum.Value) + + if state.browseSourceBtn.Clicked(gtx) { + go func() { + dir, err := zenity.SelectFile(zenity.Directory(), zenity.Title("Select a source directory.")) + if err == nil { + state.sourceResult <- dir + w.Invalidate() + } + }() + } + if state.browseDestinationBtn.Clicked(gtx) { + go func() { + dir, err := zenity.SelectFile(zenity.Directory(), zenity.Title("Select a destination directory.")) + if err == nil { + state.destinationResult <- dir + w.Invalidate() + } + }() + } + if state.startBtn.Clicked(gtx) { + startDeflickering(w, state) + } +} + +// startDeflickering reads the current widget values into the settings, then +// runs the deflickering process in the background while the native progress +// bar and start button give the user feedback. +func startDeflickering(w *app.Window, state *uiState) { + state.Settings.SourceDirectory = filepath.ToSlash(state.sourceEditor.Text()) + state.Settings.DestinationDirectory = filepath.ToSlash(state.destinationEditor.Text()) + if v, err := strconv.Atoi(state.rollingAvgEditor.Text()); err == nil { + state.Settings.RollingAverage = v + } + if v, err := strconv.Atoi(state.jpegQualityEditor.Text()); err == nil { + state.Settings.JpegQuality = v + } + + if validationErrors := state.Settings.Validate(); len(validationErrors) > 0 { + msg := "" + for _, validationError := range validationErrors { + msg += validationError.Error() + "\n" + } + go zenity.Error(msg, zenity.Title("Simple Deflicker - Invalid settings")) + return + } + + state.processing = true + state.statusText = "Processing..." + settings := state.Settings + + go func() { + updater := &guiUpdater{state: state, win: w} + err := deflicker.Run(settings, updater) + state.deflickerResult <- err + w.Invalidate() + }() +} + +// guiUpdater implements progress.Updater by writing progress into the UiState +// and invalidating the window so the native progress bar redraws. +type guiUpdater struct { + state *uiState + win *app.Window +} + +func (u *guiUpdater) Start() { + u.state.setProgress(0, "Starting...") + u.win.Invalidate() +} + +func (u *guiUpdater) Increment(msg string, phase string, completed int, ofTotal int) { + var fraction float32 + if ofTotal > 0 { + fraction = float32(completed) / float32(ofTotal) + } + u.state.setProgress(fraction, fmt.Sprintf("%s: %s (%d/%d)", phase, msg, completed, ofTotal)) + u.win.Invalidate() +} + +func (u *guiUpdater) Finish() { + u.state.setProgress(1, "Finishing...") + u.win.Invalidate() +} diff --git a/internal/ui/layout.go b/internal/ui/layout.go new file mode 100644 index 0000000..e4e129d --- /dev/null +++ b/internal/ui/layout.go @@ -0,0 +1,139 @@ +package ui + +import ( + "image/color" + + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + "github.com/struffel/simple-deflicker/internal/deflicker" +) + +const version = "0.4.0" + +func layoutGui(gtx layout.Context, th *material.Theme, state *uiState) layout.Dimensions { + return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + controlsDisabled := state.processing + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + // Title + layout.Rigid(material.H6(th, "Simple Deflicker").Layout), + layout.Rigid(material.Caption(th, "Version "+version).Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + + // Source directory + layout.Rigid(material.Body1(th, "Source Directory").Layout), + layout.Rigid(fullWidthBorderedEditor(th, &state.sourceEditor, controlsDisabled)), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(buttonWithState(th, &state.browseSourceBtn, "Browse", controlsDisabled)), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + + // Destination directory + layout.Rigid(material.Body1(th, "Destination Directory").Layout), + layout.Rigid(fullWidthBorderedEditor(th, &state.destinationEditor, controlsDisabled)), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(buttonWithState(th, &state.browseDestinationBtn, "Browse", controlsDisabled)), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + + // Advanced settings + layout.Rigid(material.Body1(th, "Advanced settings").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + jpegQualityDisabled := controlsDisabled || state.Settings.OutFormat != deflicker.FormatJpeg + return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, + layout.Flexed(1, labeledEditor(th, "Rolling average", &state.rollingAvgEditor, controlsDisabled)), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Flexed(1, labeledEditor(th, "JPEG quality", &state.jpegQualityEditor, jpegQualityDisabled)), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Flexed(1, formatSelector(th, state, controlsDisabled)), + ) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout), + + // Start button and progress bar + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + text := "Start" + if state.processing { + text = "Processing..." + if _, progressText := state.progress(); progressText != "" { + text = progressText + } + } + return buttonWithState(th, &state.startBtn, text, controlsDisabled)(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(20)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + fraction, _ := state.progress() + bar := material.ProgressBar(th, fraction) + bar.Height = unit.Dp(8) + return bar.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + ) + }) +} + +func buttonWithState(th *material.Theme, btn *widget.Clickable, label string, disabled bool) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + if disabled { + gtx = gtx.Disabled() + } + return material.Button(th, btn, label).Layout(gtx) + } +} + +func formatSelector(th *material.Theme, state *uiState, disabled bool) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + caption := material.Caption(th, "Output format") + if disabled { + caption.Color = disabledColor(th) + gtx = gtx.Disabled() + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(caption.Layout), + layout.Rigid(material.RadioButton(th, &state.formatEnum, string(deflicker.FormatPng), "PNG").Layout), + layout.Rigid(material.RadioButton(th, &state.formatEnum, string(deflicker.FormatJpeg), "JPEG").Layout), + ) + } +} + +func labeledEditor(th *material.Theme, label string, ed *widget.Editor, disabled bool) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + caption := material.Caption(th, label) + if disabled { + caption.Color = disabledColor(th) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(caption.Layout), + layout.Rigid(fullWidthBorderedEditor(th, ed, disabled)), + ) + } +} + +// disabledColor returns the theme foreground color at half opacity, used to +// visually gray out disabled controls. +func disabledColor(th *material.Theme) color.NRGBA { + c := th.Fg + c.A = c.A / 2 + return c +} + +func fullWidthBorderedEditor(th *material.Theme, ed *widget.Editor, disabled bool) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + borderColor := th.Fg + if disabled { + gtx = gtx.Disabled() + borderColor = disabledColor(th) + } + border := widget.Border{Color: borderColor, Width: unit.Dp(1), CornerRadius: unit.Dp(4)} + return border.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + editorStyle := material.Editor(th, ed, "") + if disabled { + editorStyle.Color = disabledColor(th) + editorStyle.HintColor = disabledColor(th) + } + return layout.UniformInset(unit.Dp(6)).Layout(gtx, editorStyle.Layout) + }) + } +} diff --git a/internal/ui/state.go b/internal/ui/state.go new file mode 100644 index 0000000..5da5f7d --- /dev/null +++ b/internal/ui/state.go @@ -0,0 +1,78 @@ +package ui + +import ( + "strconv" + "sync" + + "gioui.org/widget" + "github.com/struffel/simple-deflicker/internal/deflicker" +) + +type uiState struct { + Settings deflicker.Settings + + sourceResult chan string + destinationResult chan string + deflickerResult chan error + + processing bool + statusText string + + progressMu sync.Mutex + progressFraction float32 + progressText string + + sourceEditor widget.Editor + destinationEditor widget.Editor + rollingAvgEditor widget.Editor + jpegQualityEditor widget.Editor + formatEnum widget.Enum + + browseSourceBtn widget.Clickable + browseDestinationBtn widget.Clickable + startBtn widget.Clickable +} + +func newUiState(settings deflicker.Settings) *uiState { + state := &uiState{ + Settings: settings, + sourceResult: make(chan string, 1), + destinationResult: make(chan string, 1), + deflickerResult: make(chan error, 1), + processing: false, + statusText: "", + } + state.sourceEditor.SingleLine = true + state.sourceEditor.SetText(settings.SourceDirectory) + + state.destinationEditor.SingleLine = true + state.destinationEditor.SetText(settings.DestinationDirectory) + + state.rollingAvgEditor.SingleLine = true + state.rollingAvgEditor.Filter = "0123456789" + state.rollingAvgEditor.SetText(strconv.Itoa(settings.RollingAverage)) + + state.jpegQualityEditor.SingleLine = true + state.jpegQualityEditor.Filter = "0123456789" + state.jpegQualityEditor.SetText(strconv.Itoa(settings.JpegQuality)) + + state.formatEnum.Value = string(settings.OutFormat) + return state +} + +// setProgress updates the current progress fraction (0..1) and status text. +// It is safe to call from any goroutine. +func (s *uiState) setProgress(fraction float32, text string) { + s.progressMu.Lock() + s.progressFraction = fraction + s.progressText = text + s.progressMu.Unlock() +} + +// progress returns the current progress fraction (0..1) and status text. It +// is safe to call from any goroutine. +func (s *uiState) progress() (float32, string) { + s.progressMu.Lock() + defer s.progressMu.Unlock() + return s.progressFraction, s.progressText +} diff --git a/main.go b/main.go deleted file mode 100644 index ab31557..0000000 --- a/main.go +++ /dev/null @@ -1,142 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "os" - "runtime" - - "github.com/disintegration/imaging" -) - -type picture struct { - currentPath string - targetPath string - currentRgbHistogram rgbHistogram - targetRgbHistogram rgbHistogram -} - -func main() { - //Initial console output - printInfo() - //Read parameters from console - config = collectConfigInformation() - if shouldRunCli(config) { - deflickeringError := runDeflickering() - if deflickeringError != nil { - clear() - fmt.Println("An error occured:") - fmt.Println(deflickeringError) - os.Exit(1) - } - os.Exit(0) - } - //Initialize Window from config and start GUI - guiError := startGUI() - if guiError != nil { - fmt.Println(guiError) - os.Exit(1) - } - os.Exit(0) -} - -func shouldRunCli(config configuration) bool { - return config.sourceDirectory != "" || config.destinationDirectory != "" -} - -func runDeflickering() error { - - //Prepare - configError := validateConfigInformation() - if configError != nil { - return configError - } - clear() - runtime.GOMAXPROCS(config.threads) - pictures, picturesError := readDirectory(config.sourceDirectory, config.destinationDirectory) - if picturesError != nil { - return picturesError - } - progress := createProgressBars(len(pictures)) - progress.container.Start() - - //Analyze and create Histograms - var analyzeError error - pictures, analyzeError = forEveryPicture(pictures, progress.bars["analyze"], config.threads, func(pic picture) (picture, error) { - img, err := imaging.Open(pic.currentPath) - if err != nil { - return pic, errors.New(pic.currentPath + " | " + err.Error()) - } - pic.currentRgbHistogram = generateRgbHistogramFromImage(img) - return pic, nil - }) - if analyzeError != nil { - progress.container.Stop() - return analyzeError - } - - //Calculate global or rolling average - if config.rollingAverage < 1 { - var averageRgbHistogram rgbHistogram - for i := range pictures { - for j := 0; j < 256; j++ { - averageRgbHistogram.r[j] += pictures[i].currentRgbHistogram.r[j] - averageRgbHistogram.g[j] += pictures[i].currentRgbHistogram.g[j] - averageRgbHistogram.b[j] += pictures[i].currentRgbHistogram.b[j] - } - } - for i := 0; i < 256; i++ { - averageRgbHistogram.r[i] /= uint32(len(pictures)) - averageRgbHistogram.g[i] /= uint32(len(pictures)) - averageRgbHistogram.b[i] /= uint32(len(pictures)) - } - for i := range pictures { - pictures[i].targetRgbHistogram = averageRgbHistogram - } - } else { - for i := range pictures { - var averageRgbHistogram rgbHistogram - var start = i - config.rollingAverage - if start < 0 { - start = 0 - } - var end = i + config.rollingAverage - if end > len(pictures)-1 { - end = len(pictures) - 1 - } - for i := start; i <= end; i++ { - for j := 0; j < 256; j++ { - averageRgbHistogram.r[j] += pictures[i].currentRgbHistogram.r[j] - averageRgbHistogram.g[j] += pictures[i].currentRgbHistogram.g[j] - averageRgbHistogram.b[j] += pictures[i].currentRgbHistogram.b[j] - } - } - for i := 0; i < 256; i++ { - averageRgbHistogram.r[i] /= uint32(end - start + 1) - averageRgbHistogram.g[i] /= uint32(end - start + 1) - averageRgbHistogram.b[i] /= uint32(end - start + 1) - } - pictures[i].targetRgbHistogram = averageRgbHistogram - } - } - - var adjustError error - pictures, adjustError = forEveryPicture(pictures, progress.bars["adjust"], config.threads, func(pic picture) (picture, error) { - var img, _ = imaging.Open(pic.currentPath) - lut := generateRgbLutFromRgbHistograms(pic.currentRgbHistogram, pic.targetRgbHistogram) - img = applyRgbLutToImage(img, lut) - err := imaging.Save(img, pic.targetPath, imaging.JPEGQuality(config.jpegCompression), imaging.PNGCompressionLevel(0)) - if err != nil { - return pic, errors.New(pic.currentPath + " | " + err.Error()) - } - return pic, nil - }) - if adjustError != nil { - progress.container.Stop() - return adjustError - } - progress.container.Stop() - clear() - fmt.Printf("Saved %v pictures into %v", len(pictures),config.destinationDirectory) - return nil -} diff --git a/progress.go b/progress.go deleted file mode 100644 index d99651c..0000000 --- a/progress.go +++ /dev/null @@ -1,44 +0,0 @@ -package main - -import ( - "fmt" - "math" - - "github.com/gosuri/uiprogress" -) - -type progressInfo struct { - container *uiprogress.Progress - bars map[string]*uiprogress.Bar -} - -func createProgressBars(numberOfPictures int) progressInfo { - var tmpProgress progressInfo - tmpProgress.container = uiprogress.New() - tmpProgress.bars = make(map[string]*uiprogress.Bar) - tmpProgress.bars["analyze"] = tmpProgress.container.AddBar(numberOfPictures).PrependCompleted().PrependElapsed() - tmpProgress.bars["adjust"] = tmpProgress.container.AddBar(numberOfPictures).PrependCompleted().PrependElapsed() - - tmpProgress.bars["analyze"].Width = 20 - tmpProgress.bars["adjust"].Width = 20 - - progressBarFunction := func(b *uiprogress.Bar, step string) string { - //Calculate the number of digits to display - n := math.Floor(math.Log10(float64(b.Total)) + 1) - f := fmt.Sprintf("%%-15v %%-%vv/%%-%vv", n, n) - return fmt.Sprintf(f, step, b.Current(), b.Total) - } - - progressBarFunctionAnalyze := func(b *uiprogress.Bar) string { - return progressBarFunction(b, "Analyzing") - } - - progressBarFunctionAdjust := func(b *uiprogress.Bar) string { - return progressBarFunction(b, "Adjusting") - } - - tmpProgress.bars["adjust"].AppendFunc(progressBarFunctionAdjust) - tmpProgress.bars["analyze"].AppendFunc(progressBarFunctionAnalyze) - - return tmpProgress -} diff --git a/util.go b/util.go deleted file mode 100644 index ca95ec7..0000000 --- a/util.go +++ /dev/null @@ -1,45 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/gosuri/uiprogress" - "github.com/inancgumus/screen" -) - -func forEveryPicture(pictures []picture, progressBar *uiprogress.Bar, threads int, f func(pic picture) (picture, error)) ([]picture, error) { - tokens := make(chan error, threads) - var err error - for i := 0; i < threads; i++ { - tokens <- nil - } - for i := range pictures { - err = <-tokens - if err != nil { - return pictures, err - } - go func(i int) { - var functionError error - defer func() { - progressBar.Incr() - tokens <- functionError - }() - pictures[i], functionError = f(pictures[i]) - }(i) - } - for i := 0; i < threads; i++ { - err = <-tokens - if err != nil { - return pictures, err - } - } - return pictures, nil -} -func printInfo() { - fmt.Println("SIMPLE DEFLICKER") - fmt.Println("v0.4.0 / github.com/struffel/simple-deflicker") -} -func clear() { - screen.MoveTopLeft() - screen.Clear() -}