├── .desktop ├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── Setup.hs ├── app-aux-files ├── Credits.html ├── Info.plist ├── perspec-gui.sh ├── perspec.sh └── portraitify.sh ├── app └── Main.hs ├── cbits ├── apply_test.c ├── perspectivetransform.c ├── perspectivetransform.h ├── simplecv.c ├── simplecv.h ├── test.c ├── tinyfiledialogs.c ├── tinyfiledialogs.c.patch ├── tinyfiledialogs.h └── tinyfiledialogs.h.patch ├── changelog.md ├── development.md ├── fourmolu.yaml ├── images ├── banner.afdesign ├── banner.png ├── building.jpg ├── building3x.jpg ├── calibration.afdesign ├── calibration.jpeg ├── calibration.png ├── calibration_landscape.afdesign ├── calibration_landscape.png ├── calibration_landscape_rotated.jpg ├── cli.png ├── cover.afdesign ├── cover@2x.png ├── cropped-fixed.jpg ├── cropped.jpg ├── desk.jpeg ├── doc-fixed.jpg ├── doc-large.jpg ├── doc-marking.jpg ├── doc.jpg ├── doc_rotated.jpg ├── error-message.jpg ├── icon.icns ├── icon.svg ├── icon_padded.png ├── icon_padded_white.png ├── icon_padded_white_512.png ├── out.jpg ├── perspec_image_dropped.png ├── perspec_marked_corners.png ├── perspec_opened.png ├── rotated.png ├── test.jpg ├── testdir │ ├── building3x copy.jpg │ └── doc copy.jpg ├── thumbnail@2x.png ├── words.afdesign ├── words.png └── words@2x.png ├── license ├── makefile ├── package.yaml ├── readme.md ├── scripts └── .gitkeep ├── source ├── Correct.hs ├── Home.hs ├── Lib.hs ├── Rename.hs ├── SimpleCV.chs ├── TinyFileDialogs.chs ├── Types.hs └── Utils.hs ├── stack.yaml ├── stack.yaml.lock ├── test ├── Spec.hs ├── example.afphoto └── example.jpg └── usage.txt /.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Perspec 3 | Type=Application 4 | Icon=icon_padded_white_512 5 | Exec=perspec 6 | Categories=Graphics;ImageProcessing;RasterGraphics;Photography;Scanning 7 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /cbits/* linguist-vendored 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Haskell Stack CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | macos: 7 | strategy: 8 | matrix: 9 | os: 10 | - macos-13 11 | - macos-14 12 | - macos-15 13 | runs-on: ${{ matrix.os }} 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Setup Stack 18 | uses: haskell-actions/setup@v2 19 | with: 20 | enable-stack: true 21 | stack-no-global: true 22 | 23 | - name: Install Platypus 24 | run: brew install --cask platypus 25 | 26 | - name: Make Platypus CLI available 27 | run: | 28 | mv /Applications/Platypus.app/Contents/Resources/platypus_clt.gz . 29 | gunzip platypus_clt.gz 30 | mv platypus_clt /usr/local/bin/platypus 31 | 32 | sudo mkdir -p /usr/local/share/platypus 33 | mv /Applications/Platypus.app/Contents/Resources/ScriptExec.gz . 34 | gunzip ScriptExec.gz 35 | sudo mv ScriptExec /usr/local/share/platypus/ 36 | 37 | sudo mv \ 38 | /Applications/Platypus.app/Contents/Resources/MainMenu.nib \ 39 | /usr/local/share/platypus/ 40 | 41 | - name: Print Platypus' version 42 | run: platypus --version 43 | 44 | - name: Install ImageMagick 45 | run: brew install imagemagick 46 | 47 | - name: Inject license keys 48 | run: 49 | sed -i '' 50 | 's/licenses = \[\]/licenses = \[${{ secrets.LICENSE_KEYS }}\]/' 51 | source/Lib.hs 52 | 53 | - name: Build Perspec App 54 | run: make Perspec.app 55 | 56 | - name: Move App to Artifact Directory 57 | run: mkdir artifact && mv Perspec.app artifact 58 | 59 | - name: Upload MacOS Release 60 | uses: actions/upload-artifact@v4 61 | with: 62 | path: artifact 63 | name: perspec-app_${{ matrix.os }}_x86_64 64 | 65 | linux: 66 | runs-on: ubuntu-24.04 67 | steps: 68 | - uses: actions/checkout@v4 69 | 70 | - name: Setup Stack 71 | uses: haskell-actions/setup@v2 72 | with: 73 | enable-stack: true 74 | stack-no-global: true 75 | 76 | - name: Add apt repository for libfuse2 77 | run: 78 | sudo add-apt-repository universe 79 | 80 | - name: Install dependencies 81 | run: 82 | sudo apt-get install 83 | imagemagick 84 | libblas-dev 85 | libblas3 86 | libfuse2 87 | libgl1-mesa-dev 88 | libglfw3 89 | libglu1-mesa-dev 90 | liblapack-dev 91 | liblapack3 92 | libxcursor-dev 93 | libxi-dev 94 | libxinerama-dev 95 | libxrandr-dev 96 | libxxf86vm-dev 97 | 98 | - name: Download bundable ImageMagick AppImage and install globally 99 | run: | 100 | curl -L -O https://imagemagick.org/archive/binaries/magick \ 101 | && chmod +x ./magick 102 | cp ./magick /usr/local/bin/magick 103 | 104 | - name: Build Perspec CLI tool 105 | run: make perspec 106 | 107 | - name: Install AppImage 108 | run: 109 | curl -L -O 110 | https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage && 111 | chmod +x linuxdeploy-x86_64.AppImage 112 | 113 | - name: Build AppImage 114 | run: 115 | ./linuxdeploy-x86_64.AppImage 116 | --executable $(which perspec) 117 | --library /usr/lib/x86_64-linux-gnu/libglfw.so.3 118 | --appdir ./AppDir 119 | --output appimage 120 | --desktop-file ./.desktop 121 | --icon-file $(pwd)/images/icon_padded_white_512.png && 122 | mv Perspec-*-x86_64.AppImage Perspec_x86_64.AppImage 123 | 124 | - name: Upload Linux Release 125 | uses: actions/upload-artifact@v4 126 | with: 127 | path: ./Perspec_x86_64.AppImage 128 | name: Perspec_x86_64.AppImage 129 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.stack-work 2 | /imagemagick 3 | /imagemagick.tar.gz 4 | /images/*-fixed.* 5 | /images/*.bmp 6 | /magick 7 | /ml-test 8 | /Perspec_macOS_* 9 | /Perspec*.app 10 | /perspec.cabal 11 | /scripts/perspectra 12 | .aider* 13 | .env 14 | -------------------------------------------------------------------------------- /Setup.hs: -------------------------------------------------------------------------------- 1 | import Distribution.Simple 2 | 3 | 4 | main = defaultMain 5 | -------------------------------------------------------------------------------- /app-aux-files/Credits.html: -------------------------------------------------------------------------------- 1 |

This app was built with Haskell and Platypus.

2 | 3 |

Following Hackage packages were used:

4 | 5 | 16 | -------------------------------------------------------------------------------- /app-aux-files/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleName 6 | PerspecSimple 7 | CFBundleIdentifier 8 | com.adriansieber.PerspecSimple 9 | CFBundleInfoDictionaryVersion 10 | 6.0 11 | CFBundleExecutable 12 | PerspecSimple 13 | CFBundleDisplayName 14 | PerspecSimple 15 | NSAppTransportSecurity 16 | 17 | NSAllowsArbitraryLoads 18 | 19 | 20 | NSPrincipalClass 21 | NSApplication 22 | CFBundleDocumentTypes 23 | 24 | 25 | CFBundleTypeExtensions 26 | 27 | jpg 28 | jpeg 29 | png 30 | gif 31 | 32 | CFBundleTypeRole 33 | Viewer 34 | LSItemContentTypes 35 | 36 | public.item 37 | public.folder 38 | 39 | 40 | 41 | CFBundlePackageType 42 | APPL 43 | LSUIElement 44 | 45 | CFBundleIconFile 46 | AppIcon.icns 47 | LSMinimumSystemVersion 48 | 10.11.0 49 | CFBundleDevelopmentRegion 50 | en 51 | NSHumanReadableCopyright 52 | © 2025 Adrian Sieber 53 | CFBundleShortVersionString 54 | 0.2.0.0-2025-01-18t22:36 55 | 56 | 57 | -------------------------------------------------------------------------------- /app-aux-files/perspec-gui.sh: -------------------------------------------------------------------------------- 1 | #! /bin/dash 2 | 3 | if test $# = 0 4 | then ./perspec gui 5 | else ./perspec fix "$@" 6 | fi 7 | -------------------------------------------------------------------------------- /app-aux-files/perspec.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | if test $# = 0 4 | then 5 | 6 | # Lines must contain at least one space or they are discarded 7 | printf " " 8 | printf " 9 | USAGE 10 | ──────────────────────────────────────────────────────────────────────────────── 11 | " 12 | printf " 13 | 1. Drop the image file(s) onto this window 14 | 2. Mark the corners by clicking on them 15 | 3. Press [Enter] 16 | " 17 | printf " 18 | You can also use it directly via the command line. 19 | " 20 | printf " 21 | For more information open your terminal and run \`perspec help\`. 22 | (On macOS \`/Applications/Perspec.app/Contents/Resources/perspec help\`.) 23 | " 24 | printf " 25 | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 26 | " 27 | 28 | else ./perspec fix "$@" 29 | fi 30 | -------------------------------------------------------------------------------- /app-aux-files/portraitify.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | find "$1" -iname '*.jpg' -or -iname '*.jpeg' | \ 4 | while read -r file 5 | do 6 | mogrify "$file" -rotate "-90>" 7 | done 8 | -------------------------------------------------------------------------------- /app/Main.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedRecordDot #-} 2 | {-# LANGUAGE QuasiQuotes #-} 3 | {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} 4 | 5 | {-# HLINT ignore "Replace case with maybe" #-} 6 | 7 | module Main where 8 | 9 | import Protolude ( 10 | Bool (True), 11 | Char, 12 | Either (Left, Right), 13 | IO, 14 | Maybe (Just, Nothing), 15 | Monad ((>>=)), 16 | die, 17 | getArgs, 18 | otherwise, 19 | reads, 20 | when, 21 | writeFile, 22 | ($), 23 | (&), 24 | (<&>), 25 | ) 26 | import Protolude qualified as P 27 | 28 | import Data.Text (pack, unpack) 29 | import Data.Text qualified as T 30 | import Data.Yaml (decodeFileEither, prettyPrintParseException) 31 | import System.Console.Docopt as Docopt ( 32 | Arguments, 33 | Docopt, 34 | Option, 35 | argument, 36 | command, 37 | docoptFile, 38 | getAllArgs, 39 | getArg, 40 | getArgOrExitWith, 41 | isPresent, 42 | longOption, 43 | parseArgsOrExit, 44 | ) 45 | import System.Directory ( 46 | XdgDirectory (..), 47 | createDirectoryIfMissing, 48 | getXdgDirectory, 49 | listDirectory, 50 | makeAbsolute, 51 | renameFile, 52 | ) 53 | import System.FilePath (()) 54 | 55 | import Control.Arrow ((>>>)) 56 | import Lib (loadAndStart) 57 | import Rename (getRenamingBatches) 58 | import Types ( 59 | Config, 60 | RenameMode (Even, Odd, Sequential), 61 | SortOrder (Ascending, Descending), 62 | TransformBackend (HipBackend, ImageMagickBackend, SimpleCVBackend), 63 | transformBackendFlag, 64 | ) 65 | 66 | 67 | patterns :: Docopt 68 | patterns = [docoptFile|usage.txt|] 69 | 70 | 71 | getArgOrExit :: Arguments -> Docopt.Option -> IO [Char] 72 | getArgOrExit = getArgOrExitWith patterns 73 | 74 | 75 | execWithArgs :: Config -> [[Char]] -> IO () 76 | execWithArgs confFromFile cliArgs = do 77 | args <- parseArgsOrExit patterns cliArgs 78 | 79 | let config = case args `getArg` longOption "backend" of 80 | Nothing -> confFromFile 81 | Just backend -> 82 | confFromFile 83 | { transformBackendFlag = 84 | backend 85 | & ( T.pack 86 | >>> T.toLower 87 | >>> \case 88 | "hip" -> HipBackend 89 | "imagemagick" -> ImageMagickBackend 90 | _ -> SimpleCVBackend 91 | ) 92 | } 93 | 94 | when (args `isPresent` command "gui") $ do 95 | loadAndStart config Nothing 96 | 97 | when (args `isPresent` command "fix") $ do 98 | let files = args `getAllArgs` argument "file" 99 | filesAbs <- files & P.mapM makeAbsolute 100 | 101 | loadAndStart config (Just filesAbs) 102 | 103 | when (args `isPresent` command "rename") $ do 104 | directory <- args `getArgOrExit` argument "directory" 105 | 106 | let 107 | startNumberMb = 108 | args 109 | `getArg` longOption "start-with" 110 | <&> reads 111 | & ( \case 112 | Just [(int, _)] -> Just int 113 | _ -> Nothing 114 | ) 115 | 116 | renameMode 117 | | args `isPresent` longOption "even" = Even 118 | | args `isPresent` longOption "odd" = Odd 119 | | otherwise = Sequential 120 | 121 | sortOrder = 122 | if args `isPresent` longOption "descending" 123 | then Descending 124 | else Ascending 125 | 126 | files <- listDirectory directory 127 | 128 | let 129 | renamingBatches = 130 | getRenamingBatches 131 | startNumberMb 132 | renameMode 133 | sortOrder 134 | (files <&> pack) 135 | 136 | renamingBatches 137 | & P.mapM_ 138 | ( \renamings -> 139 | renamings 140 | & P.mapM_ 141 | ( \(file, target) -> 142 | renameFile 143 | (directory unpack file) 144 | (directory unpack target) 145 | ) 146 | ) 147 | 148 | 149 | main :: IO () 150 | main = do 151 | let appName = "Perspec" 152 | 153 | configDirectory <- getXdgDirectory XdgConfig appName 154 | createDirectoryIfMissing True configDirectory 155 | 156 | let configPath = configDirectory "config.yaml" 157 | 158 | configResult <- decodeFileEither configPath 159 | 160 | case configResult of 161 | Left error -> do 162 | if "file not found" 163 | `T.isInfixOf` T.pack (prettyPrintParseException error) 164 | then do 165 | writeFile configPath "licenseKey:\n" 166 | configResult2 <- decodeFileEither configPath 167 | 168 | case configResult2 of 169 | Left error2 -> die $ T.pack $ prettyPrintParseException error2 170 | Right config -> do 171 | getArgs >>= execWithArgs config 172 | else die $ T.pack $ prettyPrintParseException error 173 | Right config -> do 174 | getArgs >>= execWithArgs config 175 | -------------------------------------------------------------------------------- /cbits/apply_test.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include "simplecv.h" 7 | #include "perspectivetransform.h" 8 | 9 | 10 | int translate_matrix_test() { 11 | printf("🎬 Start translate matrix test …\n"); 12 | // Flip both axes 13 | Matrix3x3 tmat = { 14 | 1, 0, -2, 15 | 0, 1, -2, 16 | 0, 0, 1 17 | }; 18 | 19 | int width = 4; 20 | int height = 4; 21 | unsigned char input_data[64] = { 22 | 1,1,1,255, 7,7,7,255, 0,0,0,255, 0,0,0,255, 23 | 2,2,2,255, 3,3,3,255, 0,0,0,255, 0,0,0,255, 24 | 0,0,0,255, 0,0,0,255, 0,0,0,255, 0,0,0,255, 25 | 0,0,0,255, 0,0,0,255, 0,0,0,255, 0,0,0,255 26 | }; 27 | unsigned char expected_data[64] = { 28 | 0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0, 29 | 0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0, 30 | 0,0,0,0, 0,0,0,0, 1,1,1,255, 7,7,7,255, 31 | 0,0,0,0, 0,0,0,0, 2,2,2,255, 3,3,3,255 32 | }; 33 | 34 | // Apply transformation 35 | unsigned char* output_data = apply_matrix_3x3( 36 | width, height, input_data, 37 | width, height, 38 | &tmat 39 | ); 40 | 41 | // Compare the output_data with the expected data 42 | for (int i = 0; i < width * height * 4; i++) { 43 | if (output_data[i] != expected_data[i]) { 44 | printf( 45 | "Mismatch at index %d: Expected: %u, Got: %u\n", 46 | i, 47 | expected_data[i], 48 | output_data[i] 49 | ); 50 | return 1; 51 | } 52 | } 53 | 54 | free(output_data); 55 | 56 | return 0; 57 | } 58 | 59 | 60 | int flip_matrix_test() { 61 | printf("🎬 Start flip matrix test …\n"); 62 | // Flip both axes 63 | Matrix3x3 tmat = { 64 | -1, 0, 0, 65 | 0, -1, 0, 66 | 0, 0, 1 67 | }; 68 | 69 | int width = 4; 70 | int height = 4; 71 | unsigned char input_data[64] = { 72 | 1,1,1,255, 2,2,2,255, 9,9,9,255, 8,8,8,255, 73 | 2,2,2,255, 1,1,1,255, 9,9,9,255, 7,7,7,255, 74 | 2,2,2,255, 0,0,0,255, 8,8,8,255, 2,2,2,255, 75 | 0,0,0,255, 2,2,2,255, 9,9,9,255, 8,8,8,255 76 | }; 77 | unsigned char expected_data[64] = { 78 | 8,8,8,255, 9,9,9,255, 2,2,2,255, 0,0,0,255, 79 | 2,2,2,255, 8,8,8,255, 0,0,0,255, 2,2,2,255, 80 | 7,7,7,255, 9,9,9,255, 1,1,1,255, 2,2,2,255, 81 | 8,8,8,255, 9,9,9,255, 2,2,2,255, 1,1,1,255 82 | }; 83 | 84 | // Apply transformation 85 | unsigned char* output_data = apply_matrix_3x3( 86 | width, height, input_data, 87 | width, height, 88 | &tmat 89 | ); 90 | 91 | // Compare the output_data with the expected data 92 | for (int i = 0; i < width * height * 4; i++) { 93 | if (output_data[i] != expected_data[i]) { 94 | printf( 95 | "Mismatch at index %d: Expected: %u, Got: %u\n", 96 | i, 97 | expected_data[i], 98 | output_data[i] 99 | ); 100 | return 1; 101 | } 102 | } 103 | 104 | free(output_data); 105 | 106 | return 0; 107 | } 108 | 109 | 110 | int scale_matrix_test() { 111 | printf("🎬 Start scale matrix test …\n"); 112 | // Scale image by 50% 113 | Matrix3x3 tmat = { 114 | 2, 0 , 0, 115 | 0 , 2, 0, 116 | 0 , 0 , 1 117 | }; 118 | 119 | int in_width = 4; 120 | int in_height = 4; 121 | unsigned char input_data[64] = { 122 | 1,1,1,255, 1,1,1,255, 9,9,9,255, 9,9,9,255, 123 | 1,1,1,255, 1,1,1,255, 9,9,9,255, 9,9,9,255, 124 | 2,2,2,255, 2,2,2,255, 6,6,6,255, 6,6,6,255, 125 | 2,2,2,255, 2,2,2,255, 6,6,6,255, 6,6,6,255, 126 | }; 127 | 128 | int out_width = 4; 129 | int out_height = 4; 130 | unsigned char expected_data[64] = { 131 | 1,1,1,255, 9,9,9,255, 0,0,0,0, 0,0,0,0, 132 | 2,2,2,255, 6,6,6,255, 0,0,0,0, 0,0,0,0, 133 | 0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0, 134 | 0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0 135 | }; 136 | 137 | // Apply transformation 138 | unsigned char* output_data = apply_matrix_3x3( 139 | in_width, in_height, input_data, 140 | out_width, out_height, 141 | &tmat 142 | ); 143 | 144 | // Compare the output_data with the expected data 145 | for (int i = 0; i < out_width * out_height * 4; i++) { 146 | if (output_data[i] != expected_data[i]) { 147 | printf( 148 | "Mismatch at index %d: %u != %u\n", 149 | i, output_data[i], expected_data[i] 150 | ); 151 | return 1; 152 | } 153 | } 154 | 155 | free(output_data); 156 | 157 | return 0; 158 | } 159 | 160 | 161 | int main () { 162 | if ( 163 | !translate_matrix_test() && 164 | !flip_matrix_test() && 165 | !scale_matrix_test() 166 | ) { 167 | printf("✅ All tests passed\n"); 168 | return 0; 169 | } 170 | else { 171 | printf("❌ Some tests failed\n"); 172 | return 1; 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /cbits/perspectivetransform.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | #include "perspectivetransform.h" 10 | 11 | // #define DEBUG_LOGGING 12 | 13 | #ifdef DEBUG_LOGGING 14 | #define log(msg) printf("DEBUG: %s\n", msg) 15 | #else 16 | #define log(msg) // No operation 17 | #endif 18 | 19 | 20 | /** 21 | * Helper function to solve 8x8 linear system using Gaussian elimination 22 | * Returns 1 on success, 0 on failure 23 | */ 24 | int solve_linear_system(double A[8][8], double b[8], double x[8]) { 25 | const int n = 8; 26 | const double epsilon = 1e-10; 27 | int i, j, k; 28 | 29 | // Create augmented matrix [A|b] with extra safety margin 30 | double aug[8][10]; // One extra column for safety 31 | 32 | // Initialize augmented matrix 33 | for(i = 0; i < n; i++) { 34 | for(j = 0; j < n; j++) { 35 | aug[i][j] = A[i][j]; 36 | } 37 | aug[i][n] = b[i]; 38 | } 39 | 40 | // Gaussian elimination with partial pivoting 41 | for(i = 0; i < n; i++) { 42 | // Find pivot 43 | int max_row = i; 44 | double max_val = fabs(aug[i][i]); 45 | 46 | for(k = i + 1; k < n; k++) { 47 | if(fabs(aug[k][i]) > max_val) { 48 | max_val = fabs(aug[k][i]); 49 | max_row = k; 50 | } 51 | } 52 | 53 | // Check for singularity 54 | if(max_val < epsilon) { 55 | log("Warning: Matrix is nearly singular\n"); 56 | return 0; 57 | } 58 | 59 | // Swap maximum row with current row 60 | if(max_row != i) { 61 | for(j = 0; j <= n; j++) { 62 | double temp = aug[i][j]; 63 | aug[i][j] = aug[max_row][j]; 64 | aug[max_row][j] = temp; 65 | } 66 | } 67 | 68 | // Eliminate column i 69 | for(j = i + 1; j < n; j++) { 70 | double factor = aug[j][i] / aug[i][i]; 71 | for(k = i; k <= n; k++) { 72 | aug[j][k] -= factor * aug[i][k]; 73 | } 74 | } 75 | } 76 | 77 | // Back substitution 78 | for(i = n - 1; i >= 0; i--) { 79 | if(fabs(aug[i][i]) < epsilon) { 80 | log("Warning: Zero pivot encountered\n"); 81 | return 0; 82 | } 83 | 84 | x[i] = aug[i][n]; 85 | for(j = i + 1; j < n; j++) { 86 | x[i] -= aug[i][j] * x[j]; 87 | } 88 | x[i] /= aug[i][i]; 89 | 90 | // Check for invalid results 91 | if(isnan(x[i]) || isinf(x[i])) { 92 | log("Warning: Invalid result detected\n"); 93 | return 0; 94 | } 95 | } 96 | 97 | return 1; 98 | } 99 | 100 | 101 | /** 102 | * Calculate the perspective transformation matrix 103 | * from the source and destination corner coordinates. 104 | */ 105 | Matrix3x3 *calculate_perspective_transform( 106 | Corners *src_corners, 107 | Corners *dst_corners 108 | ) { 109 | // Initialize matrices with zeros 110 | double A[8][8] = {{0}}; 111 | double b[8] = {0}; 112 | double x[8] = {0}; 113 | 114 | // Identity matrix as fallback 115 | static Matrix3x3 identity = { 116 | 1.0, 0.0, 0.0, 117 | 0.0, 1.0, 0.0, 118 | 0.0, 0.0, 1.0 119 | }; 120 | 121 | if (!src_corners || !dst_corners) { 122 | log("Error: NULL pointer passed to calculate_perspective_transform\n"); 123 | return &identity; 124 | } 125 | 126 | #ifdef DEBUG_LOGGING 127 | printf("[C] Calculating perspective transform:\n"); 128 | printf("src_corners:\ntl(%f, %f)\ntr(%f, %f)\nbr(%f, %f)\nbl(%f, %f)\n\n", 129 | src_corners->tl_x, src_corners->tl_y, 130 | src_corners->tr_x, src_corners->tr_y, 131 | src_corners->br_x, src_corners->br_y, 132 | src_corners->bl_x, src_corners->bl_y 133 | ); 134 | printf("dst_corners:\ntl(%f, %f)\ntr(%f, %f)\nbr(%f, %f)\nbl(%f, %f)\n\n", 135 | dst_corners->tl_x, dst_corners->tl_y, 136 | dst_corners->tr_x, dst_corners->tr_y, 137 | dst_corners->br_x, dst_corners->br_y, 138 | dst_corners->bl_x, dst_corners->bl_y 139 | ); 140 | #endif 141 | 142 | // Validate input coordinates 143 | if ( 144 | isnan(src_corners->tl_x) || isnan(src_corners->tl_y) || 145 | isnan(src_corners->tr_x) || isnan(src_corners->tr_y) || 146 | isnan(src_corners->br_x) || isnan(src_corners->br_y) || 147 | isnan(src_corners->bl_x) || isnan(src_corners->bl_y) || 148 | isnan(dst_corners->tl_x) || isnan(dst_corners->tl_y) || 149 | isnan(dst_corners->tr_x) || isnan(dst_corners->tr_y) || 150 | isnan(dst_corners->br_x) || isnan(dst_corners->br_y) || 151 | isnan(dst_corners->bl_x) || isnan(dst_corners->bl_y) 152 | ) { 153 | log("Error: Invalid coordinates (NaN) detected\n"); 154 | return &identity; 155 | } 156 | 157 | // Set up the system of equations 158 | for(int i = 0; i < 4; i++) { 159 | double srcX = 0.0, srcY = 0.0, dstX = 0.0, dstY = 0.0; 160 | 161 | // Safely extract coordinates 162 | switch(i) { 163 | case 0: // Top-left 164 | srcX = src_corners->tl_x; srcY = src_corners->tl_y; 165 | dstX = dst_corners->tl_x; dstY = dst_corners->tl_y; 166 | break; 167 | case 1: // Top-right 168 | srcX = src_corners->tr_x; srcY = src_corners->tr_y; 169 | dstX = dst_corners->tr_x; dstY = dst_corners->tr_y; 170 | break; 171 | case 2: // Bottom-right 172 | srcX = src_corners->br_x; srcY = src_corners->br_y; 173 | dstX = dst_corners->br_x; dstY = dst_corners->br_y; 174 | break; 175 | case 3: // Bottom-left 176 | srcX = src_corners->bl_x; srcY = src_corners->bl_y; 177 | dstX = dst_corners->bl_x; dstY = dst_corners->bl_y; 178 | break; 179 | } 180 | 181 | // Validate extracted coordinates 182 | if (isinf(srcX) || isinf(srcY) || isinf(dstX) || isinf(dstY)) { 183 | log("Error: Invalid coordinates (Inf) detected\n"); 184 | return &identity; 185 | } 186 | 187 | // First four equations for x coordinates 188 | A[i][0] = srcX; 189 | A[i][1] = srcY; 190 | A[i][2] = 1.0; 191 | A[i][6] = -srcX * dstX; 192 | A[i][7] = -srcY * dstX; 193 | b[i] = dstX; 194 | 195 | // Last four equations for y coordinates 196 | A[i+4][3] = srcX; 197 | A[i+4][4] = srcY; 198 | A[i+4][5] = 1.0; 199 | A[i+4][6] = -srcX * dstY; 200 | A[i+4][7] = -srcY * dstY; 201 | b[i+4] = dstY; 202 | } 203 | 204 | log("Solve the system of equations …\n"); 205 | if (!solve_linear_system(A, b, x)) { 206 | log("Failed to solve system, returning identity matrix\n"); 207 | return &identity; 208 | } 209 | 210 | // Validate solution 211 | for (int i = 0; i < 8; i++) { 212 | if (isnan(x[i]) || isinf(x[i]) || fabs(x[i]) > 1e6) { 213 | log("Error: Invalid solution values detected\n"); 214 | return &identity; 215 | } 216 | } 217 | 218 | Matrix3x3* result = malloc(sizeof(Matrix3x3)); 219 | *result = (Matrix3x3) { 220 | x[0], x[1], x[2], 221 | x[3], x[4], x[5], 222 | x[6], x[7], 1.0 223 | }; 224 | 225 | #ifdef DEBUG_LOGGING 226 | printf("Result matrix:\n"); 227 | printf("%f, %f, %f\n", result->m00, result->m01, result->m02); 228 | printf("%f, %f, %f\n", result->m10, result->m11, result->m12); 229 | printf("%f, %f, %f\n", result->m20, result->m21, result->m22); 230 | #endif 231 | 232 | // Final validation of the result matrix 233 | if ( 234 | isnan(result->m00) || isnan(result->m01) || isnan(result->m02) || 235 | isnan(result->m10) || isnan(result->m11) || isnan(result->m12) || 236 | isnan(result->m20) || isnan(result->m21) || isnan(result->m22) || 237 | isinf(result->m00) || isinf(result->m01) || isinf(result->m02) || 238 | isinf(result->m10) || isinf(result->m11) || isinf(result->m12) || 239 | isinf(result->m20) || isinf(result->m21) || isinf(result->m22) 240 | ) { 241 | log("Error: Invalid values in result matrix\n"); 242 | return &identity; 243 | } 244 | 245 | return result; 246 | } 247 | 248 | 249 | /** 250 | * Apply the transformation matrix to the input image 251 | * and store the result in the output image. 252 | * Use bilinear interpolation to calculate final pixel values. 253 | */ 254 | unsigned char *apply_matrix_3x3( 255 | int in_width, 256 | int in_height, 257 | unsigned char* in_data, 258 | int out_width, 259 | int out_height, 260 | Matrix3x3* tmat 261 | ) { 262 | #ifdef DEBUG_LOGGING 263 | printf("Input data:\n"); 264 | for (int i = 0; i < in_width; i++) { 265 | for (int j = 0; j < in_height; j++) { 266 | printf("%d ", in_data[(i * in_width + j) * 4]); 267 | } 268 | printf("\n"); 269 | } 270 | #endif 271 | 272 | // Patch flip matrix if needed 273 | if ( 274 | fabs(tmat->m00 + 1.0) < 1e-9 && 275 | fabs(tmat->m11 + 1.0) < 1e-9 && 276 | tmat->m02 == 0.0 && 277 | tmat->m12 == 0.0 278 | ) { 279 | tmat->m02 = in_width - 1; 280 | tmat->m12 = in_height - 1; 281 | } 282 | 283 | unsigned char *out_data = calloc( 284 | out_width * out_height * 4, 285 | sizeof(unsigned char) 286 | ); 287 | 288 | if (!out_data) { // Memory allocation failed 289 | return NULL; 290 | } 291 | 292 | // Iterate through every pixel in the output image 293 | for (int out_y = 0; out_y < out_height; ++out_y) { 294 | for (int out_x = 0; out_x < out_width; ++out_x) { 295 | // Apply the inverse transformation to find the corresponding source pixel 296 | double w = tmat->m20 * out_x + tmat->m21 * out_y + tmat->m22; 297 | if (fabs(w) < 1e-10) continue; // Skip if w is too close to zero 298 | 299 | double srcX = (tmat->m00 * out_x + tmat->m01 * out_y + tmat->m02) / w; 300 | double srcY = (tmat->m10 * out_x + tmat->m11 * out_y + tmat->m12) / w; 301 | 302 | // Convert source coordinates to integers 303 | int x0 = (int)floor(srcX); 304 | int y0 = (int)floor(srcY); 305 | int x1 = x0 + 1; 306 | int y1 = y0 + 1; 307 | 308 | // Verify that the anchor pixel is inside the source image 309 | if (x0 >= 0 && x0 < in_width && y0 >= 0 && y0 < in_height) { 310 | 311 | // Clamp the neighbor coordinates so that a (degenerated) 312 | // bilinear interpolation can be applied at the image borders. 313 | int x1c = (x1 < in_width) ? x1 : x0; 314 | int y1c = (y1 < in_height) ? y1 : y0; 315 | 316 | double dx = srcX - x0; 317 | double dy = srcY - y0; 318 | 319 | // If a neighbour got clamped we force the corresponding weight to 0 320 | if (x1c == x0) dx = 0.0; 321 | if (y1c == y0) dy = 0.0; 322 | 323 | unsigned char *p00 = &in_data[(y0 * in_width + x0 ) * 4]; 324 | unsigned char *p01 = &in_data[(y0 * in_width + x1c) * 4]; 325 | unsigned char *p10 = &in_data[(y1c * in_width + x0 ) * 4]; 326 | unsigned char *p11 = &in_data[(y1c * in_width + x1c) * 4]; 327 | 328 | for (int c = 0; c < 4; ++c) { 329 | out_data[(out_y * out_width + out_x) * 4 + c] = (unsigned char)( 330 | p00[c] * (1 - dx) * (1 - dy) + 331 | p01[c] * dx * (1 - dy) + 332 | p10[c] * (1 - dx) * dy + 333 | p11[c] * dx * dy 334 | ); 335 | } 336 | } 337 | } 338 | } 339 | 340 | #ifdef DEBUG_LOGGING 341 | printf("Output data:\n"); 342 | for (int i = 0; i < out_width; i++) { 343 | for (int j = 0; j < out_height; j++) { 344 | printf("%d ", out_data[(i * out_width + j) * 4]); 345 | } 346 | printf("\n"); 347 | } 348 | #endif 349 | 350 | return out_data; 351 | } 352 | -------------------------------------------------------------------------------- /cbits/perspectivetransform.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | typedef struct { 4 | double x; 5 | double y; 6 | } Point2D; 7 | 8 | typedef struct { 9 | double tl_x, tl_y; 10 | double tr_x, tr_y; 11 | double br_x, br_y; 12 | double bl_x, bl_y; 13 | } Corners; 14 | 15 | typedef struct { 16 | double m00, m01, m02; 17 | double m10, m11, m12; 18 | double m20, m21, m22; 19 | } Matrix3x3; 20 | 21 | Matrix3x3* calculate_perspective_transform( 22 | Corners* src_corners, 23 | Corners* dst_corners 24 | ); 25 | 26 | unsigned char * apply_matrix_3x3( 27 | int in_width, 28 | int in_height, 29 | unsigned char* in_data, 30 | int out_width, 31 | int out_height, 32 | Matrix3x3* tmat 33 | ); 34 | -------------------------------------------------------------------------------- /cbits/simplecv.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | 10 | #include "simplecv.h" 11 | #include "perspectivetransform.h" 12 | 13 | // Avoid using floating point arithmetic by pre-multiplying the weights 14 | const unsigned char R_WEIGHT = 76; // 0.299 * 256 15 | const unsigned char G_WEIGHT = 150; // 0.587 * 256 16 | const unsigned char B_WEIGHT = 30; // 0.114 * 256 17 | 18 | /** 19 | * Convert raw RGBA row-major top-to-bottom image data 20 | * to RGBA row-major top-to-bottom grayscale image data. 21 | * 22 | * @param width Width of the image. 23 | * @param height Height of the image. 24 | * @param data Pointer to the pixel data. 25 | * @return Pointer to the grayscale image data. 26 | */ 27 | unsigned char const * const grayscale( 28 | unsigned int width, 29 | unsigned int height, 30 | unsigned char const * const data 31 | ) { 32 | unsigned int img_length_byte = width * height * 4; 33 | unsigned char *grayscale_data = malloc(img_length_byte); 34 | 35 | if (!grayscale_data) { // Memory allocation failed 36 | return NULL; 37 | } 38 | 39 | // Process each pixel row by row 40 | for (unsigned int i = 0; i < width * height; i++) { 41 | unsigned int rgba_index = i * 4; 42 | 43 | unsigned char r = data[rgba_index]; 44 | unsigned char g = data[rgba_index + 1]; 45 | unsigned char b = data[rgba_index + 2]; 46 | 47 | unsigned char gray = (r * R_WEIGHT + g * G_WEIGHT + b * B_WEIGHT) >> 8; 48 | 49 | grayscale_data[rgba_index] = gray; 50 | grayscale_data[rgba_index + 1] = gray; 51 | grayscale_data[rgba_index + 2] = gray; 52 | grayscale_data[rgba_index + 3] = 255; 53 | } 54 | 55 | return grayscale_data; 56 | } 57 | 58 | 59 | /** 60 | * Convert raw RGBA row-major top-to-bottom image data 61 | * to RGBA row-major top-to-bottom grayscale image data 62 | * with a stretched contrast range. 63 | * Set the 1.5625 % darkest pixels to 0 and the 1.5625 % brightest to 255. 64 | * Uses this specific value for speed: x * 1.5625 % = x >> 6 65 | * The rest of the pixel values are linearly scaled to the range [0, 255]. 66 | 67 | * @param width Width of the image. 68 | * @param height Height of the image. 69 | * @param data Pointer to the pixel data. 70 | * @return Pointer to the grayscale image data. 71 | */ 72 | unsigned char const * const grayscale_stretch( 73 | unsigned int width, 74 | unsigned int height, 75 | unsigned char const * const data 76 | ) { 77 | unsigned int img_length_byte = width * height * 4; 78 | unsigned char *grayscale_data = malloc(img_length_byte); 79 | 80 | if (!grayscale_data) { // Memory allocation failed 81 | return NULL; 82 | } 83 | 84 | unsigned int img_length_px = width * height; 85 | // Ignore 1.5625 % of the pixels 86 | unsigned int num_pixels_to_ignore = img_length_px >> 6; 87 | 88 | unsigned char *gray_values = malloc(img_length_px); 89 | if (!gray_values) { // Memory allocation failed 90 | free(grayscale_data); 91 | return NULL; 92 | } 93 | 94 | // Process each pixel row by row to get grayscale values 95 | for (unsigned int i = 0; i < img_length_px; i++) { 96 | unsigned int rgba_index = i * 4; 97 | 98 | unsigned char r = data[rgba_index]; 99 | unsigned char g = data[rgba_index + 1]; 100 | unsigned char b = data[rgba_index + 2]; 101 | 102 | gray_values[i] = (r * R_WEIGHT + g * G_WEIGHT + b * B_WEIGHT) >> 8; 103 | } 104 | 105 | // Use counting sort to find the 1.5625% darkest and brightest pixels 106 | unsigned int histogram[256] = {0}; 107 | for (unsigned int i = 0; i < img_length_px; i++) { 108 | histogram[gray_values[i]]++; 109 | } 110 | 111 | unsigned int cumulative_count = 0; 112 | unsigned char min_val = 0; 113 | for (unsigned int i = 0; i < 256; i++) { 114 | cumulative_count += histogram[i]; 115 | if (cumulative_count > num_pixels_to_ignore) { 116 | min_val = i; 117 | break; 118 | } 119 | } 120 | 121 | cumulative_count = 0; 122 | unsigned char max_val = 255; 123 | for (int i = 255; i >= 0; i--) { 124 | cumulative_count += histogram[i]; 125 | if (cumulative_count > num_pixels_to_ignore) { 126 | max_val = i; 127 | break; 128 | } 129 | } 130 | 131 | free(gray_values); 132 | 133 | unsigned char range = max_val - min_val; 134 | 135 | // Process each pixel row by row 136 | for (unsigned int i = 0; i < img_length_px; i++) { 137 | unsigned int rgba_index = i * 4; 138 | 139 | unsigned char r = data[rgba_index]; 140 | unsigned char g = data[rgba_index + 1]; 141 | unsigned char b = data[rgba_index + 2]; 142 | 143 | unsigned char gray = (r * R_WEIGHT + g * G_WEIGHT + b * B_WEIGHT) >> 8; 144 | 145 | if (gray < min_val) { 146 | gray = 0; 147 | } else if (gray > max_val) { 148 | gray = 255; 149 | } else { 150 | gray = (gray - min_val) * 255 / range; 151 | } 152 | 153 | grayscale_data[rgba_index] = gray; 154 | grayscale_data[rgba_index + 1] = gray; 155 | grayscale_data[rgba_index + 2] = gray; 156 | grayscale_data[rgba_index + 3] = 255; 157 | } 158 | 159 | return grayscale_data; 160 | } 161 | 162 | 163 | /** 164 | * Convert raw RGBA row-major top-to-bottom image data 165 | * to a single channel grayscale image data. 166 | * 167 | * @param width Width of the image. 168 | * @param height Height of the image. 169 | * @param data Pointer to the pixel data. 170 | * @return Pointer to the single channel grayscale image data. 171 | */ 172 | unsigned char *rgba_to_grayscale( 173 | unsigned int width, 174 | unsigned int height, 175 | unsigned char const * const data 176 | ) { 177 | unsigned int img_length_px = width * height; 178 | unsigned char *grayscale_data = malloc(img_length_px); 179 | 180 | if (!grayscale_data) { // Memory allocation failed 181 | return NULL; 182 | } 183 | 184 | // Process each pixel row by row 185 | for (unsigned int i = 0; i < width * height; i++) { 186 | unsigned int rgba_index = i * 4; 187 | 188 | unsigned char r = data[rgba_index]; 189 | unsigned char g = data[rgba_index + 1]; 190 | unsigned char b = data[rgba_index + 2]; 191 | 192 | unsigned char gray = (r * R_WEIGHT + g * G_WEIGHT + b * B_WEIGHT) >> 8; 193 | 194 | grayscale_data[i] = gray; 195 | } 196 | 197 | return grayscale_data; 198 | } 199 | 200 | 201 | /** 202 | * Apply a global threshold to the image data. 203 | * 204 | * @param img_length_px Length of the image data in pixels. 205 | * @param data Pointer to the image data. 206 | * @param threshold Threshold value. 207 | * 208 | */ 209 | void apply_global_threshold( 210 | unsigned int img_length_px, 211 | unsigned char *data, 212 | unsigned char threshold 213 | ) { 214 | for (unsigned int i = 0; i < img_length_px; i++) { 215 | data[i] = data[i] > threshold ? 255 : 0; 216 | } 217 | } 218 | 219 | 220 | /** 221 | * Applies two thresholds to the image data by blackening pixels 222 | * below the lower threshold and whitening pixels above the upper threshold. 223 | * Pixels between the two thresholds are scaled to the range [0, 255]. 224 | * 225 | * @param img_length_px Length of the image data in pixels. 226 | * @param data Pointer to the image data. 227 | * @param lower_threshold Every pixel below this value will be blackened. 228 | * @param upper_threshold Every pixel above this value will be whitened. 229 | * 230 | */ 231 | void apply_double_threshold( 232 | unsigned int img_length_px, 233 | unsigned char *data, 234 | unsigned char lower_threshold, 235 | unsigned char upper_threshold 236 | ) { 237 | for (unsigned int i = 0; i < img_length_px; i++) { 238 | if (data[i] < lower_threshold) { 239 | data[i] = 0; 240 | } 241 | else if (data[i] > upper_threshold) { 242 | data[i] = 255; 243 | } 244 | else { 245 | data[i] = (data[i] - lower_threshold) * 255 / (upper_threshold - lower_threshold); 246 | } 247 | } 248 | } 249 | 250 | 251 | /** 252 | * Convert single channel grayscale image data to 253 | * RGBA row-major top-to-bottom image data. 254 | * 255 | * @param width Width of the image. 256 | * @param height Height of the image. 257 | * @param data Pointer to the pixel data. 258 | */ 259 | unsigned char const * const single_to_multichannel( 260 | unsigned int width, 261 | unsigned int height, 262 | unsigned char const * const data 263 | ) { 264 | unsigned int img_length_px = width * height; 265 | unsigned char *multichannel_data = malloc(img_length_px * 4); 266 | 267 | if (!multichannel_data) { // Memory allocation failed 268 | return NULL; 269 | } 270 | 271 | for (unsigned int i = 0; i < img_length_px; i++) { 272 | unsigned int rgba_index = i * 4; 273 | multichannel_data[rgba_index] = data[i]; 274 | multichannel_data[rgba_index + 1] = data[i]; 275 | multichannel_data[rgba_index + 2] = data[i]; 276 | multichannel_data[rgba_index + 3] = 255; 277 | } 278 | 279 | return multichannel_data; 280 | } 281 | 282 | 283 | /** 284 | * Apply Otsu's thresholding algorithm to the image data. 285 | * 286 | * @param width Width of the image. 287 | * @param height Height of the image. 288 | * @param use_double_threshold Whether to use double thresholding. 289 | * @param data Pointer to the pixel data. 290 | * @return Pointer to the monochrome image data. 291 | */ 292 | unsigned char const * const otsu_threshold_rgba( 293 | unsigned int width, 294 | unsigned int height, 295 | bool use_double_threshold, 296 | unsigned char const * const data 297 | ) { 298 | unsigned char *grayscale_img = rgba_to_grayscale(width, height, data); 299 | unsigned int img_length_px = width * height; 300 | 301 | unsigned int histogram[256] = {0}; 302 | for (unsigned int i = 0; i < img_length_px; i++) { 303 | histogram[grayscale_img[i]]++; 304 | } 305 | 306 | float histogram_norm[256] = {0}; 307 | for (unsigned int i = 0; i < 256; i++) { 308 | histogram_norm[i] = (float)histogram[i] / img_length_px; 309 | } 310 | 311 | float global_mean = 0.0; 312 | 313 | for (unsigned int i = 0; i < 256; i++) { 314 | global_mean += i * histogram_norm[i]; 315 | } 316 | 317 | float cumulative_sum = 0.0; 318 | float cumulative_mean = 0.0; 319 | float max_variance = 0.0; 320 | int optimal_threshold = 0; 321 | 322 | for (unsigned int i = 0; i < 256; i++) { 323 | cumulative_sum += histogram_norm[i]; 324 | cumulative_mean += i * histogram_norm[i]; 325 | 326 | if (cumulative_sum == 0 || cumulative_sum == 1) { 327 | continue; 328 | } 329 | 330 | float mean1 = cumulative_mean / cumulative_sum; 331 | float mean2 = (global_mean - cumulative_mean) / (1 - cumulative_sum); 332 | 333 | float class_variance = cumulative_sum * (1 - cumulative_sum) * 334 | (mean1 - mean2) * (mean1 - mean2); 335 | 336 | if (class_variance > max_variance) { 337 | max_variance = class_variance; 338 | optimal_threshold = i; 339 | } 340 | } 341 | 342 | const int threshold_range_offset = 16; 343 | 344 | if (use_double_threshold) { 345 | apply_double_threshold( 346 | img_length_px, 347 | grayscale_img, 348 | optimal_threshold - threshold_range_offset, 349 | optimal_threshold + threshold_range_offset 350 | ); 351 | } 352 | else { 353 | apply_global_threshold( 354 | img_length_px, 355 | grayscale_img, 356 | optimal_threshold 357 | ); 358 | } 359 | 360 | unsigned char const * const monochrome_data = single_to_multichannel( 361 | width, 362 | height, 363 | grayscale_img 364 | ); 365 | 366 | free(grayscale_img); 367 | 368 | return monochrome_data; 369 | } 370 | 371 | 372 | /** 373 | * Apply gaussian blur to the image data. 374 | * 375 | * @param width Width of the image. 376 | * @param height Height of the image. 377 | * @param data Pointer to the pixel data. 378 | * @return Pointer to the blurred image data. 379 | */ 380 | unsigned char const * const apply_gaussian_blur( 381 | unsigned int width, 382 | unsigned int height, 383 | double radius, 384 | unsigned char const * const data 385 | ) { 386 | unsigned int img_length_px = width * height; 387 | unsigned char *blurred_data = malloc(img_length_px * 4); 388 | 389 | if (!blurred_data) { // Memory allocation failed 390 | return NULL; 391 | } 392 | 393 | unsigned int kernel_size = 2 * radius + 1; 394 | float *kernel = malloc(kernel_size * sizeof(float)); 395 | 396 | if (!kernel) { // Memory allocation failed 397 | free(blurred_data); 398 | return NULL; 399 | } 400 | 401 | float sigma = radius / 3.0; 402 | float sigma_sq = sigma * sigma; 403 | float two_sigma_sq = 2 * sigma_sq; 404 | float sqrt_two_pi_sigma = sqrt(2 * M_PI) * sigma; 405 | 406 | for (unsigned int i = 0; i < kernel_size; i++) { 407 | int x = i - radius; 408 | kernel[i] = exp(-(x * x) / two_sigma_sq) / sqrt_two_pi_sigma; 409 | } 410 | 411 | // Apply the kernel in the horizontal direction 412 | for (unsigned int y = 0; y < height; y++) { 413 | for (unsigned int x = 0; x < width; x++) { 414 | float r_sum = 0.0; 415 | float g_sum = 0.0; 416 | float b_sum = 0.0; 417 | float weight_sum = 0.0; 418 | 419 | for (int k = -radius; k <= radius; k++) { 420 | int x_offset = x + k; 421 | if (x_offset < 0 || x_offset >= width) { 422 | continue; 423 | } 424 | 425 | unsigned int img_index = y * width + x_offset; 426 | unsigned int img_rgba_index = img_index * 4; 427 | 428 | float weight = kernel[k + (int)radius]; 429 | weight_sum += weight; 430 | 431 | r_sum += data[img_rgba_index] * weight; 432 | g_sum += data[img_rgba_index + 1] * weight; 433 | b_sum += data[img_rgba_index + 2] * weight; 434 | } 435 | 436 | unsigned int rgba_index = (y * width + x) * 4; 437 | blurred_data[rgba_index] = r_sum / weight_sum; 438 | blurred_data[rgba_index + 1] = g_sum / weight_sum; 439 | blurred_data[rgba_index + 2] = b_sum / weight_sum; 440 | blurred_data[rgba_index + 3] = 255; 441 | } 442 | } 443 | 444 | // Apply the kernel in the vertical direction 445 | for (unsigned int x = 0; x < width; x++) { 446 | for (unsigned int y = 0; y < height; y++) { 447 | float r_sum = 0.0; 448 | float g_sum = 0.0; 449 | float b_sum = 0.0; 450 | float weight_sum = 0.0; 451 | 452 | for (int k = -radius; k <= radius; k++) { 453 | int y_offset = y + k; 454 | if (y_offset < 0 || y_offset >= height) { 455 | continue; 456 | } 457 | 458 | unsigned int img_index = y_offset * width + x; 459 | unsigned int img_rgba_index = img_index * 4; 460 | 461 | float weight = kernel[k + (int)radius]; 462 | weight_sum += weight; 463 | 464 | r_sum += blurred_data[img_rgba_index] * weight; 465 | g_sum += blurred_data[img_rgba_index + 1] * weight; 466 | b_sum += blurred_data[img_rgba_index + 2] * weight; 467 | } 468 | 469 | unsigned int rgba_index = (y * width + x) * 4; 470 | blurred_data[rgba_index] = r_sum / weight_sum; 471 | blurred_data[rgba_index + 1] = g_sum / weight_sum; 472 | blurred_data[rgba_index + 2] = b_sum / weight_sum; 473 | blurred_data[rgba_index + 3] = 255; 474 | } 475 | } 476 | 477 | free(kernel); 478 | 479 | return blurred_data; 480 | } 481 | 482 | 483 | /** 484 | * Convert image to anti-aliased black and white. 485 | * 1. Convert the image to grayscale. 486 | * 2. Subtract blurred image from the original image to get the high frequencies. 487 | * 3. Apply OTSU's threshold to get the optimal threshold. 488 | * 4. Apply the threshold + offset to get the anti-aliased image. 489 | * 490 | * @param width Width of the image. 491 | * @param height Height of the image. 492 | * @param data Pointer to the pixel data. 493 | * @return Pointer to the blurred image data. 494 | */ 495 | unsigned char const * const bw_smart( 496 | unsigned int width, 497 | unsigned int height, 498 | bool use_double_threshold, 499 | unsigned char const * const data 500 | ) { 501 | unsigned char const * const grayscale_data = grayscale(width, height, data); 502 | 503 | // Calculate blur radius dependent on image size 504 | // (Empirical formula after testing) 505 | double blurRadius = (sqrt((double)width * (double)height)) * 0.1; 506 | 507 | unsigned char const * const blurred_data = apply_gaussian_blur( 508 | width, 509 | height, 510 | blurRadius, 511 | grayscale_data 512 | ); 513 | 514 | unsigned int img_length_px = width * height; 515 | unsigned char *high_freq_data = malloc(img_length_px * 4); 516 | 517 | if (!high_freq_data) { // Memory allocation failed 518 | free((void *)grayscale_data); 519 | free((void *)blurred_data); 520 | return NULL; 521 | } 522 | 523 | // Subtract blurred image from the original image to get the high frequencies 524 | // and invert the high frequencies to get a white background. 525 | for (unsigned int i = 0; i < img_length_px; i++) { 526 | unsigned int rgba_idx = i * 4; 527 | int high_freq_val = 127 + grayscale_data[rgba_idx] - blurred_data[rgba_idx]; 528 | high_freq_data[rgba_idx] = high_freq_val; // R 529 | high_freq_data[rgba_idx + 1] = high_freq_val; // G 530 | high_freq_data[rgba_idx + 2] = high_freq_val; // B 531 | high_freq_data[rgba_idx + 3] = 255; // A 532 | } 533 | 534 | free((void *)grayscale_data); 535 | free((void *)blurred_data); 536 | 537 | unsigned char const * const final_data = otsu_threshold_rgba( 538 | width, height, use_double_threshold, high_freq_data 539 | ); 540 | 541 | free(high_freq_data); 542 | return final_data; 543 | } 544 | -------------------------------------------------------------------------------- /cbits/simplecv.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | unsigned char const * const apply_gaussian_blur( 6 | unsigned int width, 7 | unsigned int height, 8 | double radius, 9 | unsigned char const * const data 10 | ); 11 | 12 | unsigned char const * const grayscale( 13 | unsigned int width, 14 | unsigned int height, 15 | unsigned char const * const data 16 | ); 17 | 18 | unsigned char const * const grayscale_stretch( 19 | unsigned int width, 20 | unsigned int height, 21 | unsigned char const * const data 22 | ); 23 | 24 | void apply_global_threshold( 25 | unsigned int img_length, 26 | unsigned char * data, 27 | unsigned char threshold 28 | ); 29 | 30 | unsigned char const * const otsu_threshold_rgba( 31 | unsigned int width, 32 | unsigned int height, 33 | bool use_double_threshold, 34 | unsigned char const * const data 35 | ); 36 | 37 | unsigned char const * const bw_smart( 38 | unsigned int width, 39 | unsigned int height, 40 | bool use_double_threshold, 41 | unsigned char const * const data 42 | ); 43 | -------------------------------------------------------------------------------- /cbits/test.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include "simplecv.h" 7 | #include "perspectivetransform.h" 8 | 9 | 10 | int test_otsu_threshold () { 11 | unsigned int width = 4; 12 | unsigned int height = 4; 13 | unsigned char data[64] = { 14 | 1,1,1,255, 2,2,2,255, 9,9,9,255, 8,8,8,255, 15 | 2,2,2,255, 1,1,1,255, 9,9,9,255, 7,7,7,255, 16 | 2,2,2,255, 0,0,0,255, 8,8,8,255, 2,2,2,255, 17 | 0,0,0,255, 2,2,2,255, 9,9,9,255, 8,8,8,255 18 | }; 19 | 20 | unsigned char const * const monochrome_data = otsu_threshold_rgba(width, height, false, data); 21 | 22 | unsigned char expected_data[64] = { 23 | 0,0,0,255, 0,0,0,255, 255,255,255,255, 255,255,255,255, 24 | 0,0,0,255, 0,0,0,255, 255,255,255,255, 255,255,255,255, 25 | 0,0,0,255, 0,0,0,255, 255,255,255,255, 0,0,0,255, 26 | 0,0,0,255, 0,0,0,255, 255,255,255,255, 255,255,255,255 27 | }; 28 | 29 | bool test_ok = true; 30 | 31 | for (unsigned int i = 0; i < 64; i++) { 32 | if (monochrome_data[i] != expected_data[i]) { 33 | test_ok = false; 34 | printf( 35 | "Test failed at index %d: Monochrome data %d != Expected data %d\n", 36 | i, 37 | monochrome_data[i], 38 | expected_data[i] 39 | ); 40 | // return 1; 41 | } 42 | } 43 | 44 | free((void*)monochrome_data); 45 | 46 | if (test_ok) { 47 | printf("✅ Otsu's threshold test passed\n"); 48 | return 0; 49 | } 50 | else { 51 | printf("❌ Test failed\n"); 52 | return 1; 53 | } 54 | } 55 | 56 | 57 | int test_perspective_transform() { 58 | Corners src = { 59 | 100, 100, // Top-left 60 | 400, 150, // Top-right 61 | 380, 400, // Bottom-right 62 | 120, 380 // Bottom-left 63 | }; 64 | 65 | Corners dst = { 66 | 0, 0, // Top-left 67 | 300, 0, // Top-right 68 | 300, 300, // Bottom-right 69 | 0, 300 // Bottom-left 70 | }; 71 | 72 | Matrix3x3* tmat = calculate_perspective_transform(&src, &dst); 73 | 74 | double eps = 0.001; 75 | bool test_ok = true; 76 | 77 | if(fabs(tmat->m00 - 0.85256062) > eps){ printf("m00: %f\n", tmat->m00 ); test_ok = false;} 78 | if(fabs(tmat->m01 + 0.06089719) > eps){ printf("m01: %f\n", tmat->m01 ); test_ok = false;} 79 | if(fabs(tmat->m02 + 79.16634335) > eps){ printf("m02: %f\n", tmat->m02 ); test_ok = false;} 80 | if(fabs(tmat->m10 + 0.14503146) > eps){ printf("m10: %f\n", tmat->m10 ); test_ok = false;} 81 | if(fabs(tmat->m11 - 0.87018875) > eps){ printf("m11: %f\n", tmat->m11 ); test_ok = false;} 82 | if(fabs(tmat->m12 + 72.51572949) > eps){ printf("m12: %f\n", tmat->m12 ); test_ok = false;} 83 | if(fabs(tmat->m20 + 0.00022582) > eps){ printf("m20: %f\n", tmat->m20 ); test_ok = false;} 84 | if(fabs(tmat->m21 + 0.00044841) > eps){ printf("m21: %f\n", tmat->m21 ); test_ok = false;} 85 | if(fabs(tmat->m22 - 1) > eps){ printf("m22: %f\n", tmat->m22 ); test_ok = false;} 86 | 87 | if (test_ok) { 88 | printf("✅ Perspective transform test passed\n"); 89 | return 0; 90 | } 91 | else { 92 | printf("❌ Perspective transform test failed\n"); 93 | return 1; 94 | } 95 | } 96 | 97 | int test_perspective_transform_float() { 98 | Corners src = { 99 | 278.44, 182.23, // Top-left 100 | 1251.25, 178.79, // Top-right 101 | 1395.63, 718.48, // Bottom-right 102 | 216.56, 770.04 // Bottom-left 103 | }; 104 | 105 | Corners dst = { 106 | 0, 0, // Top-left 107 | 1076.5, 0, // Top-right 108 | 1076.5, 574.86, // Bottom-right 109 | 0, 574.86 // Bottom-left 110 | }; 111 | 112 | Matrix3x3 *tmat = calculate_perspective_transform(&src, &dst); 113 | 114 | double eps = 0.001; 115 | bool test_ok = true; 116 | 117 | if(fabs(tmat->m00 - 1.08707) > eps){ printf("m00: %f\n", tmat->m00 ); test_ok = false;} 118 | if(fabs(tmat->m01 - 0.114438) > eps){ printf("m01: %f\n", tmat->m01 ); test_ok = false;} 119 | if(fabs(tmat->m02 + 323.538) > eps){ printf("m02: %f\n", tmat->m02 ); test_ok = false;} 120 | if(fabs(tmat->m10 - 0.00445981) > eps){ printf("m10: %f\n", tmat->m10 ); test_ok = false;} 121 | if(fabs(tmat->m11 - 1.26121) > eps){ printf("m11: %f\n", tmat->m11 ); test_ok = false;} 122 | if(fabs(tmat->m12 + 231.072) > eps){ printf("m12: %f\n", tmat->m12 ); test_ok = false;} 123 | if(fabs(tmat->m20 + 0.0000708899) > eps){ printf("m20: %f\n", tmat->m20 ); test_ok = false;} 124 | if(fabs(tmat->m21 - 0.000395421) > eps){ printf("m21: %f\n", tmat->m21 ); test_ok = false;} 125 | if(fabs(tmat->m22 - 1) > eps){ printf("m22: %f\n", tmat->m22 ); test_ok = false;} 126 | 127 | if (test_ok) { 128 | printf("✅ Perspective transform with floats test passed\n"); 129 | return 0; 130 | } 131 | else { 132 | printf("❌ Perspective transform with floats test failed\n"); 133 | return 1; 134 | } 135 | } 136 | 137 | 138 | int main () { 139 | if ( 140 | !test_otsu_threshold() && 141 | !test_perspective_transform() && 142 | !test_perspective_transform_float() 143 | ) { 144 | printf("✅ All tests passed\n"); 145 | return 0; 146 | } 147 | else { 148 | printf("❌ Some tests failed\n"); 149 | return 1; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /cbits/tinyfiledialogs.c.patch: -------------------------------------------------------------------------------- 1 | --- upstream/tinyfiledialogs.c 2019-10-23 13:14:06.000000000 -0500 2 | +++ cbits/tinyfiledialogs.c 2019-10-23 13:22:36.000000000 -0500 3 | @@ -88,7 +88,8 @@ 4 | */ 5 | 6 | #ifndef __sun 7 | -#define _POSIX_C_SOURCE 2 /* to accept POSIX 2 in old ANSI C standards */ 8 | +// MT: removing because this breaks Windows GHCi. don't know why! 9 | +// #define _POSIX_C_SOURCE 2 /* to accept POSIX 2 in old ANSI C standards */ 10 | #endif 11 | 12 | #include 13 | @@ -118,7 +119,8 @@ 14 | #include 15 | #define TINYFD_NOCCSUNICODE 16 | #define SLASH "\\" 17 | - int tinyfd_winUtf8 = 0 ; /* on windows string char can be 0:MBCS or 1:UTF-8 */ 18 | +// MT: set below to 1 as we are using the char version on all platforms 19 | + int tinyfd_winUtf8 = 1 ; /* on windows string char can be 0:MBCS or 1:UTF-8 */ 20 | #else 21 | #include 22 | #include 23 | @@ -141,7 +143,8 @@ 24 | #if defined(TINYFD_NOLIB) && defined(_WIN32) 25 | int tinyfd_forceConsole = 1 ; 26 | #else 27 | -int tinyfd_forceConsole = 0 ; /* 0 (default) or 1 */ 28 | +// MT: control via Haskell flag 29 | +int tinyfd_forceConsole = HSFORCECONSOLE ; /* 0 (default) or 1 */ 30 | #endif 31 | /* for unix & windows: 0 (graphic mode) or 1 (console mode). 32 | 0: try to use a graphic solution, if it fails then it uses console mode. 33 | -------------------------------------------------------------------------------- /cbits/tinyfiledialogs.h: -------------------------------------------------------------------------------- 1 | /*_________ 2 | / \ tinyfiledialogs.h v3.4 [Oct 24, 2019] zlib licence 3 | |tiny file| Unique header file created [November 9, 2014] 4 | | dialogs | Copyright (c) 2014 - 2018 Guillaume Vareille http://ysengrin.com 5 | \____ ___/ http://tinyfiledialogs.sourceforge.net 6 | \| git clone http://git.code.sf.net/p/tinyfiledialogs/code tinyfd 7 | ____________________________________________ 8 | | | 9 | | email: tinyfiledialogs at ysengrin.com | 10 | |____________________________________________| 11 | ________________________________________________________________________ 12 | | | 13 | | the windows only wchar_t UTF-16 prototypes are at the end of this file | 14 | |________________________________________________________________________| 15 | 16 | Please upvote my stackoverflow answer https://stackoverflow.com/a/47651444 17 | 18 | tiny file dialogs (cross-platform C C++) 19 | InputBox PasswordBox MessageBox ColorPicker 20 | OpenFileDialog SaveFileDialog SelectFolderDialog 21 | Native dialog library for WINDOWS MAC OSX GTK+ QT CONSOLE & more 22 | SSH supported via automatic switch to console mode or X11 forwarding 23 | 24 | one C file + a header (add them to your C or C++ project) with 8 functions: 25 | - beep 26 | - notify popup (tray) 27 | - message & question 28 | - input & password 29 | - save file 30 | - open file(s) 31 | - select folder 32 | - color picker 33 | 34 | Complements OpenGL Vulkan GLFW GLUT GLUI VTK SFML TGUI 35 | SDL Ogre Unity3d ION OpenCV CEGUI MathGL GLM CPW GLOW 36 | Open3D IMGUI MyGUI GLT NGL STB & GUI less programs 37 | 38 | NO INIT 39 | NO MAIN LOOP 40 | NO LINKING 41 | NO INCLUDE 42 | 43 | The dialogs can be forced into console mode 44 | 45 | Windows (XP to 10) ASCII MBCS UTF-8 UTF-16 46 | - native code & vbs create the graphic dialogs 47 | - enhanced console mode can use dialog.exe from 48 | http://andrear.altervista.org/home/cdialog.php 49 | - basic console input 50 | 51 | Unix (command line calls) ASCII UTF-8 52 | - applescript, kdialog, zenity 53 | - python (2 or 3) + tkinter + python-dbus (optional) 54 | - dialog (opens a console if needed) 55 | - basic console input 56 | The same executable can run across desktops & distributions 57 | 58 | C89 & C++98 compliant: tested with C & C++ compilers 59 | VisualStudio MinGW-gcc GCC Clang TinyCC OpenWatcom-v2 BorlandC SunCC ZapCC 60 | on Windows Mac Linux Bsd Solaris Minix Raspbian 61 | using Gnome Kde Enlightenment Mate Cinnamon Budgie Unity Lxde Lxqt Xfce 62 | WindowMaker IceWm Cde Jds OpenBox Awesome Jwm Xdm 63 | 64 | Bindings for LUA and C# dll, Haskell 65 | Included in LWJGL(java), Rust, Allegrobasic 66 | 67 | - License - 68 | 69 | This software is provided 'as-is', without any express or implied 70 | warranty. In no event will the authors be held liable for any damages 71 | arising from the use of this software. 72 | 73 | Permission is granted to anyone to use this software for any purpose, 74 | including commercial applications, and to alter it and redistribute it 75 | freely, subject to the following restrictions: 76 | 77 | 1. The origin of this software must not be misrepresented; you must not 78 | claim that you wrote the original software. If you use this software 79 | in a product, an acknowledgment in the product documentation would be 80 | appreciated but is not required. 81 | 2. Altered source versions must be plainly marked as such, and must not be 82 | misrepresented as being the original software. 83 | 3. This notice may not be removed or altered from any source distribution. 84 | */ 85 | 86 | #ifndef TINYFILEDIALOGS_H 87 | #define TINYFILEDIALOGS_H 88 | 89 | /* #define TINYFD_NOLIB */ 90 | /* On windows, define TINYFD_NOLIB here 91 | if you don't want to include the code creating the graphic dialogs. 92 | Then you won't need to link against Comdlg32.lib and Ole32.lib */ 93 | 94 | /* if tinydialogs.c is compiled as C++ code rather than C code, 95 | you may need to comment out: 96 | extern "C" { 97 | and the corresponding closing bracket near the end of this file: 98 | } 99 | */ 100 | #ifdef __cplusplus 101 | extern "C" { 102 | #endif 103 | 104 | extern char const tinyfd_version[8]; /* contains tinyfd current version number */ 105 | extern char const tinyfd_needs[]; /* info about requirements */ 106 | extern int tinyfd_verbose; /* 0 (default) or 1 : on unix, prints the command line calls */ 107 | extern int tinyfd_silent; /* 1 (default) or 0 : on unix, 108 | hide errors and warnings from called dialog*/ 109 | 110 | #ifdef _WIN32 111 | /* for UTF-16 use the functions at the end of this files */ 112 | extern int tinyfd_winUtf8; /* 0 (default MBCS) or 1 (UTF-8)*/ 113 | /* on windows string char can be 0:MBCS or 1:UTF-8 114 | unless your code is really prepared for UTF-8 on windows, leave this on MBSC. 115 | Or you can use the UTF-16 (wchar) prototypes at the end of ths file.*/ 116 | #endif 117 | 118 | extern int tinyfd_forceConsole; /* 0 (default) or 1 */ 119 | /* for unix & windows: 0 (graphic mode) or 1 (console mode). 120 | 0: try to use a graphic solution, if it fails then it uses console mode. 121 | 1: forces all dialogs into console mode even when an X server is present, 122 | if the package dialog (and a console is present) or dialog.exe is installed. 123 | on windows it only make sense for console applications */ 124 | 125 | extern char tinyfd_response[1024]; 126 | /* if you pass "tinyfd_query" as aTitle, 127 | the functions will not display the dialogs 128 | but will return 0 for console mode, 1 for graphic mode. 129 | tinyfd_response is then filled with the retain solution. 130 | possible values for tinyfd_response are (all lowercase) 131 | for graphic mode: 132 | windows_wchar windows 133 | applescript kdialog zenity zenity3 matedialog qarma 134 | python2-tkinter python3-tkinter python-dbus perl-dbus 135 | gxmessage gmessage xmessage xdialog gdialog 136 | for console mode: 137 | dialog whiptail basicinput no_solution */ 138 | 139 | void tinyfd_beep(void); 140 | 141 | int tinyfd_notifyPopup( 142 | char const * const aTitle, /* NULL or "" */ 143 | char const * const aMessage, /* NULL or "" may contain \n \t */ 144 | char const * const aIconType); /* "info" "warning" "error" */ 145 | /* return has only meaning for tinyfd_query */ 146 | 147 | int tinyfd_messageBox( 148 | char const * const aTitle , /* NULL or "" */ 149 | char const * const aMessage , /* NULL or "" may contain \n \t */ 150 | char const * const aDialogType , /* "ok" "okcancel" "yesno" "yesnocancel" */ 151 | char const * const aIconType , /* "info" "warning" "error" "question" */ 152 | int const aDefaultButton ) ; 153 | /* 0 for cancel/no , 1 for ok/yes , 2 for no in yesnocancel */ 154 | 155 | char const * tinyfd_inputBox( 156 | char const * const aTitle , /* NULL or "" */ 157 | char const * const aMessage , /* NULL or "" may NOT contain \n \t on windows */ 158 | char const * const aDefaultInput ) ; /* "" , if NULL it's a passwordBox */ 159 | /* returns NULL on cancel */ 160 | 161 | char const * tinyfd_saveFileDialog( 162 | char const * const aTitle , /* NULL or "" */ 163 | char const * const aDefaultPathAndFile , /* NULL or "" */ 164 | int const aNumOfFilterPatterns , /* 0 */ 165 | char const * const * const aFilterPatterns , /* NULL | {"*.jpg","*.png"} */ 166 | char const * const aSingleFilterDescription ) ; /* NULL | "text files" */ 167 | /* returns NULL on cancel */ 168 | 169 | char const * tinyfd_openFileDialog( 170 | char const * const aTitle , /* NULL or "" */ 171 | char const * const aDefaultPathAndFile , /* NULL or "" */ 172 | int const aNumOfFilterPatterns , /* 0 */ 173 | char const * const * const aFilterPatterns , /* NULL | {"*.jpg","*.png"} */ 174 | char const * const aSingleFilterDescription , /* NULL | "image files" */ 175 | int const aAllowMultipleSelects ) ; /* 0 or 1 */ 176 | /* in case of multiple files, the separator is | */ 177 | /* returns NULL on cancel */ 178 | 179 | char const * tinyfd_selectFolderDialog( 180 | char const * const aTitle , /* NULL or "" */ 181 | char const * const aDefaultPath ) ; /* NULL or "" */ 182 | /* returns NULL on cancel */ 183 | 184 | char const * tinyfd_colorChooser( 185 | char const * const aTitle , /* NULL or "" */ 186 | char const * const aDefaultHexRGB , /* NULL or "#FF0000" */ 187 | unsigned char const aDefaultRGB[3] , /* { 0 , 255 , 255 } */ 188 | unsigned char aoResultRGB[3] ) ; /* { 0 , 0 , 0 } */ 189 | /* returns the hexcolor as a string "#FF0000" */ 190 | /* aoResultRGB also contains the result */ 191 | /* aDefaultRGB is used only if aDefaultHexRGB is NULL */ 192 | /* aDefaultRGB and aoResultRGB can be the same array */ 193 | /* returns NULL on cancel */ 194 | 195 | // MT: removing these since we're not using them and c2hs has trouble parsing 196 | #if 0 197 | 198 | /************ NOT CROSS PLATFORM SECTION STARTS HERE ************************/ 199 | #ifdef _WIN32 200 | #ifndef TINYFD_NOLIB 201 | 202 | /* windows only - utf-16 version */ 203 | int tinyfd_notifyPopupW( 204 | wchar_t const * const aTitle, /* NULL or L"" */ 205 | wchar_t const * const aMessage, /* NULL or L"" may contain \n \t */ 206 | wchar_t const * const aIconType); /* L"info" L"warning" L"error" */ 207 | 208 | /* windows only - utf-16 version */ 209 | int tinyfd_messageBoxW( 210 | wchar_t const * const aTitle , /* NULL or L"" */ 211 | wchar_t const * const aMessage, /* NULL or L"" may contain \n \t */ 212 | wchar_t const * const aDialogType, /* L"ok" L"okcancel" L"yesno" */ 213 | wchar_t const * const aIconType, /* L"info" L"warning" L"error" L"question" */ 214 | int const aDefaultButton ); /* 0 for cancel/no , 1 for ok/yes */ 215 | /* returns 0 for cancel/no , 1 for ok/yes */ 216 | 217 | /* windows only - utf-16 version */ 218 | wchar_t const * tinyfd_inputBoxW( 219 | wchar_t const * const aTitle, /* NULL or L"" */ 220 | wchar_t const * const aMessage, /* NULL or L"" may NOT contain \n nor \t */ 221 | wchar_t const * const aDefaultInput ); /* L"" , if NULL it's a passwordBox */ 222 | 223 | /* windows only - utf-16 version */ 224 | wchar_t const * tinyfd_saveFileDialogW( 225 | wchar_t const * const aTitle, /* NULL or L"" */ 226 | wchar_t const * const aDefaultPathAndFile, /* NULL or L"" */ 227 | int const aNumOfFilterPatterns, /* 0 */ 228 | wchar_t const * const * const aFilterPatterns, /* NULL or {L"*.jpg",L"*.png"} */ 229 | wchar_t const * const aSingleFilterDescription); /* NULL or L"image files" */ 230 | /* returns NULL on cancel */ 231 | 232 | /* windows only - utf-16 version */ 233 | wchar_t const * tinyfd_openFileDialogW( 234 | wchar_t const * const aTitle, /* NULL or L"" */ 235 | wchar_t const * const aDefaultPathAndFile, /* NULL or L"" */ 236 | int const aNumOfFilterPatterns , /* 0 */ 237 | wchar_t const * const * const aFilterPatterns, /* NULL {L"*.jpg",L"*.png"} */ 238 | wchar_t const * const aSingleFilterDescription, /* NULL or L"image files" */ 239 | int const aAllowMultipleSelects ) ; /* 0 or 1 */ 240 | /* in case of multiple files, the separator is | */ 241 | /* returns NULL on cancel */ 242 | 243 | /* windows only - utf-16 version */ 244 | wchar_t const * tinyfd_selectFolderDialogW( 245 | wchar_t const * const aTitle, /* NULL or L"" */ 246 | wchar_t const * const aDefaultPath); /* NULL or L"" */ 247 | /* returns NULL on cancel */ 248 | 249 | /* windows only - utf-16 version */ 250 | wchar_t const * tinyfd_colorChooserW( 251 | wchar_t const * const aTitle, /* NULL or L"" */ 252 | wchar_t const * const aDefaultHexRGB, /* NULL or L"#FF0000" */ 253 | unsigned char const aDefaultRGB[3] , /* { 0 , 255 , 255 } */ 254 | unsigned char aoResultRGB[3] ) ; /* { 0 , 0 , 0 } */ 255 | /* returns the hexcolor as a string L"#FF0000" */ 256 | /* aoResultRGB also contains the result */ 257 | /* aDefaultRGB is used only if aDefaultHexRGB is NULL */ 258 | /* aDefaultRGB and aoResultRGB can be the same array */ 259 | /* returns NULL on cancel */ 260 | 261 | 262 | #endif /*TINYFD_NOLIB*/ 263 | #else /*_WIN32*/ 264 | 265 | /* unix zenity only */ 266 | char const * tinyfd_arrayDialog( 267 | char const * const aTitle , /* NULL or "" */ 268 | int const aNumOfColumns , /* 2 */ 269 | char const * const * const aColumns, /* {"Column 1","Column 2"} */ 270 | int const aNumOfRows, /* 2 */ 271 | char const * const * const aCells); 272 | /* {"Row1 Col1","Row1 Col2","Row2 Col1","Row2 Col2"} */ 273 | 274 | #endif /*_WIN32 */ 275 | 276 | #endif // MT: end removed section 277 | 278 | #ifdef __cplusplus 279 | } 280 | #endif 281 | 282 | #endif /* TINYFILEDIALOGS_H */ 283 | 284 | /* 285 | - This is not for android nor ios. 286 | - The code is pure C, perfectly compatible with C++. 287 | - the windows only wchar_t (utf-16) prototypes are in the header file 288 | - windows is fully supported from XP to 10 (maybe even older versions) 289 | - C# & LUA via dll, see example files 290 | - OSX supported from 10.4 to latest (maybe even older versions) 291 | - Avoid using " and ' in titles and messages. 292 | - There's one file filter only, it may contain several patterns. 293 | - If no filter description is provided, 294 | the list of patterns will become the description. 295 | - char const * filterPatterns[3] = { "*.obj" , "*.stl" , "*.dxf" } ; 296 | - On windows char defaults to MBCS, set tinyfd_winUtf8=1 to use UTF-8 297 | - On windows link against Comdlg32.lib and Ole32.lib 298 | This linking is not compulsary for console mode (see above). 299 | - On unix: it tries command line calls, so no such need. 300 | - On unix you need one of the following: 301 | applescript, kdialog, zenity, matedialog, shellementary, qarma, 302 | python (2 or 3)/tkinter/python-dbus (optional), Xdialog 303 | or dialog (opens terminal if running without console) or xterm. 304 | - One of those is already included on most (if not all) desktops. 305 | - In the absence of those it will use gdialog, gxmessage or whiptail 306 | with a textinputbox. 307 | - If nothing is found, it switches to basic console input, 308 | it opens a console if needed (requires xterm + bash). 309 | - Use windows separators on windows and unix separators on unix. 310 | - String memory is preallocated statically for all the returned values. 311 | - File and path names are tested before return, they are valid. 312 | - If you pass only a path instead of path + filename, 313 | make sure it ends with a separator. 314 | - tinyfd_forceConsole=1; at run time, forces dialogs into console mode. 315 | - On windows, console mode only make sense for console applications. 316 | - On windows, Console mode is not implemented for wchar_T UTF-16. 317 | - Mutiple selects are not allowed in console mode. 318 | - The package dialog must be installed to run in enhanced console mode. 319 | It is already installed on most unix systems. 320 | - On osx, the package dialog can be installed via 321 | http://macappstore.org/dialog or http://macports.org 322 | - On windows, for enhanced console mode, 323 | dialog.exe should be copied somewhere on your executable path. 324 | It can be found at the bottom of the following page: 325 | http://andrear.altervista.org/home/cdialog.php 326 | - If dialog is missing, it will switch to basic console input. 327 | - You can query the type of dialog that will be use. 328 | - MinGW needs gcc >= v4.9 otherwise some headers are incomplete. 329 | - The Hello World (and a bit more) is on the sourceforge site: 330 | */ 331 | -------------------------------------------------------------------------------- /cbits/tinyfiledialogs.h.patch: -------------------------------------------------------------------------------- 1 | --- upstream/tinyfiledialogs.h 2019-10-23 13:14:06.000000000 -0500 2 | +++ cbits/tinyfiledialogs.h 2019-10-23 13:23:00.000000000 -0500 3 | @@ -192,6 +192,8 @@ 4 | /* aDefaultRGB and aoResultRGB can be the same array */ 5 | /* returns NULL on cancel */ 6 | 7 | +// MT: removing these since we're not using them and c2hs has trouble parsing 8 | +#if 0 9 | 10 | /************ NOT CROSS PLATFORM SECTION STARTS HERE ************************/ 11 | #ifdef _WIN32 12 | @@ -271,6 +273,8 @@ 13 | 14 | #endif /*_WIN32 */ 15 | 16 | +#endif // MT: end removed section 17 | + 18 | #ifdef __cplusplus 19 | } 20 | #endif 21 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.2.0.0 4 | 5 | - Add sidebar with save buttons (normal, grayscale, or binary) 6 | - Add sub-command "rename" for easy renaming of photos 7 | - Make corners of selection draggable 8 | - Center Perspec window on screen 9 | - Add support for > macOS 11 10 | - Display better usage information when opening the macOS app 11 | - Add a welcome banner 12 | - Support registering the app with a license key 13 | 14 | 15 | ## 0.1.3.0 16 | 17 | - Apply EXIF rotation to JPEG images 18 | - Correctly scale the image and marked corners when resizing the window 19 | - Display helpful error message when wrong file format is dropped 20 | - Support installing with `brew cask` 21 | - Use latest version of ImageMagick 22 | 23 | 24 | ## 0.1.2.0 25 | 26 | - ImageMagick is now embedded in the bundle 27 | and doesn't need to be installed globally anymore. 🎉 28 | 29 | 30 | ## 0.1.1.0 31 | 32 | This is basically a MVP release. 33 | I've used it to correct hundreds of images, 34 | so it works and is already quite useful, but there are a few limitations: 35 | 36 | - ImageMagick is not bundled and must therefore be installed globally 37 | - Due to a [render bug of gloss] the initial view is a little broken 38 | and one needs to rescale the window to force a redraw 39 | 40 | [render bug of gloss]: 41 | https://groups.google.com/forum/#!topic/haskell-gloss/iEZbzwpwvtA 42 | -------------------------------------------------------------------------------- /development.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | ## Calibration 4 | 5 | Corners of landscape calibration image: 6 | 7 | ```hs 8 | ( (200, 100) -- Red 9 | , (900, 100) -- Green 10 | , (800, 400) -- Blue 11 | , (100, 300) -- Magenta 12 | ) 13 | ``` 14 | 15 | ## Benchmarking 16 | 17 | Use the `-bench` flag to benchmark [ImageMagick] operations. 18 | For example: 19 | 20 | ```sh 21 | magick \ 22 | doc.jpg \ 23 | -bench 50 \ 24 | -virtual-pixel black \ 25 | -define distort:viewport=1191x598+0+0 \ 26 | -distort Perspective \ 27 | '277,181 0,0 214,776 0,598 1405,723 1191,598 1256,175 1191,0' \ 28 | +repage \ 29 | doc-fixed.jpg 30 | ``` 31 | 32 | 33 | ## Generate Icons 34 | 35 | With 36 | 37 | ```sh 38 | svg2icns icon.svg 39 | ``` 40 | 41 | 42 | ## New Release 43 | 44 | - [ ] Bump version number 45 | - [ ] Create new release on GitHub 46 | - [ ] Download artifacts, fix file permission, zip them, add them to release 47 | - [ ] Update the [cask file] 48 | - [ ] Update version on [Gumroad] 49 | 50 | [cask file]: https://github.com/ad-si/homebrew-tap/blob/master/Casks/perspec.rb 51 | [Gumroad]: https://gumroad.com/feram 52 | -------------------------------------------------------------------------------- /fourmolu.yaml: -------------------------------------------------------------------------------- 1 | indentation: 2 2 | function-arrows: leading 3 | comma-style: leading 4 | import-export-style: diff-friendly 5 | indent-wheres: true 6 | record-brace-space: false 7 | newlines-between-decls: 2 8 | haddock-style: multi-line-compact 9 | let-style: auto 10 | in-style: left-align 11 | respectful: true 12 | unicode: never 13 | -------------------------------------------------------------------------------- /images/banner.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/banner.afdesign -------------------------------------------------------------------------------- /images/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/banner.png -------------------------------------------------------------------------------- /images/building.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/building.jpg -------------------------------------------------------------------------------- /images/building3x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/building3x.jpg -------------------------------------------------------------------------------- /images/calibration.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/calibration.afdesign -------------------------------------------------------------------------------- /images/calibration.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/calibration.jpeg -------------------------------------------------------------------------------- /images/calibration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/calibration.png -------------------------------------------------------------------------------- /images/calibration_landscape.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/calibration_landscape.afdesign -------------------------------------------------------------------------------- /images/calibration_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/calibration_landscape.png -------------------------------------------------------------------------------- /images/calibration_landscape_rotated.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/calibration_landscape_rotated.jpg -------------------------------------------------------------------------------- /images/cli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/cli.png -------------------------------------------------------------------------------- /images/cover.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/cover.afdesign -------------------------------------------------------------------------------- /images/cover@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/cover@2x.png -------------------------------------------------------------------------------- /images/cropped-fixed.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/cropped-fixed.jpg -------------------------------------------------------------------------------- /images/cropped.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/cropped.jpg -------------------------------------------------------------------------------- /images/desk.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/desk.jpeg -------------------------------------------------------------------------------- /images/doc-fixed.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/doc-fixed.jpg -------------------------------------------------------------------------------- /images/doc-large.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/doc-large.jpg -------------------------------------------------------------------------------- /images/doc-marking.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/doc-marking.jpg -------------------------------------------------------------------------------- /images/doc.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/doc.jpg -------------------------------------------------------------------------------- /images/doc_rotated.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/doc_rotated.jpg -------------------------------------------------------------------------------- /images/error-message.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/error-message.jpg -------------------------------------------------------------------------------- /images/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/icon.icns -------------------------------------------------------------------------------- /images/icon.svg: -------------------------------------------------------------------------------- 1 | 8 | 9 | 18 | 19 | 23 | 29 | 30 | 36 | 37 | 43 | 44 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /images/icon_padded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/icon_padded.png -------------------------------------------------------------------------------- /images/icon_padded_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/icon_padded_white.png -------------------------------------------------------------------------------- /images/icon_padded_white_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/icon_padded_white_512.png -------------------------------------------------------------------------------- /images/out.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/out.jpg -------------------------------------------------------------------------------- /images/perspec_image_dropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/perspec_image_dropped.png -------------------------------------------------------------------------------- /images/perspec_marked_corners.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/perspec_marked_corners.png -------------------------------------------------------------------------------- /images/perspec_opened.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/perspec_opened.png -------------------------------------------------------------------------------- /images/rotated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/rotated.png -------------------------------------------------------------------------------- /images/test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/test.jpg -------------------------------------------------------------------------------- /images/testdir/building3x copy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/testdir/building3x copy.jpg -------------------------------------------------------------------------------- /images/testdir/doc copy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/testdir/doc copy.jpg -------------------------------------------------------------------------------- /images/thumbnail@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/thumbnail@2x.png -------------------------------------------------------------------------------- /images/words.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/words.afdesign -------------------------------------------------------------------------------- /images/words.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/words.png -------------------------------------------------------------------------------- /images/words@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/images/words@2x.png -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help 2 | help: makefile 3 | @tail -n +4 makefile | grep ".PHONY" 4 | 5 | 6 | .PHONY: test 7 | test: 8 | gcc -Wall cbits/test.c cbits/simplecv.c cbits/perspectivetransform.c -o test_bin && ./test_bin 9 | gcc -Wall cbits/simplecv.c cbits/perspectivetransform.c cbits/apply_test.c -o apply_test && ./apply_test 10 | stack test 11 | 12 | 13 | # TODO: Don't show icon in dock (https://stackoverflow.com/a/25462666/1850340) 14 | Perspec.app: ~/.local/bin/perspec 15 | platypus \ 16 | --name Perspec \ 17 | --app-icon images/icon.icns \ 18 | --interface-type 'None' \ 19 | --app-version 0.2.0.0-$$(date -u "+%Y-%m-%dT%H:%M") \ 20 | --author "Adrian Sieber" \ 21 | --bundled-file ~/.local/bin/perspec \ 22 | --bundled-file app-aux-files/Credits.html \ 23 | --bundled-file scripts \ 24 | --bundle-identifier com.adriansieber.Perspec \ 25 | --droppable \ 26 | --optimize-nib \ 27 | --overwrite \ 28 | --quit-after-execution \ 29 | --suffixes 'png|jpg|jpeg|bmp|gif|tiff|tif' \ 30 | --interpreter '/bin/dash' \ 31 | app-aux-files/perspec-gui.sh \ 32 | $@ 33 | 34 | 35 | # TODO: Fix crash after dropping image 36 | # TODO: Implement drag & drop for dock icon (WIP at macos-app-wrapper) 37 | PerspecSimple.app: ~/.local/bin/perspec 38 | mkdir -p $@ 39 | mkdir -p $@/Contents 40 | 41 | mkdir -p $@/Contents/MacOS 42 | cp $< $@/Contents/MacOS/PerspecSimple 43 | 44 | mkdir -p $@/Contents/Resources 45 | cp app-aux-files/Info.plist $@/Contents 46 | cp images/icon.icns $@/Contents/Resources/AppIcon.icns 47 | cp app-aux-files/Credits.html $@/Contents/Resources 48 | cp $< $@/Contents/Resources 49 | 50 | 51 | PerspecWithMagick.app: ~/.local/bin/perspec imagemagick 52 | platypus \ 53 | --name PerspecWithMagick \ 54 | --app-icon images/icon.icns \ 55 | --interface-type 'Text Window' \ 56 | --app-version 0.2.0.0-$$(date -u "+%Y-%m-%dT%H:%M") \ 57 | --author "Adrian Sieber" \ 58 | --bundled-file ~/.local/bin/perspec \ 59 | --bundled-file app-aux-files/Credits.html \ 60 | --bundled-file imagemagick \ 61 | --bundled-file scripts \ 62 | --bundle-identifier com.adriansieber.PerspecWithMagick \ 63 | --droppable \ 64 | --optimize-nib \ 65 | --overwrite \ 66 | --quit-after-execution \ 67 | --suffixes 'png|jpg|jpeg|bmp|gif|tiff|tif' \ 68 | --interpreter '/bin/dash' \ 69 | app-aux-files/perspec.sh \ 70 | $@ 71 | 72 | 73 | # For macOS 74 | imagemagick: 75 | curl -L \ 76 | https://download.imagemagick.org/ImageMagick/download/binaries/ImageMagick-x86_64-apple-darwin20.1.0.tar.gz \ 77 | -o imagemagick.tar.gz 78 | tar -xzf imagemagick.tar.gz 79 | 80 | rm -rf imagemagick 81 | mv ImageMagick-7.* imagemagick 82 | 83 | 84 | ~/.local/bin/perspec: app source images/banner.bmp 85 | stack install 86 | 87 | 88 | .PHONY: perspec 89 | perspec: ~/.local/bin/perspec 90 | 91 | 92 | images/banner.bmp: images/banner.png 93 | magick $< $@ 94 | 95 | 96 | .PHONY: install 97 | install: Perspec.app 98 | rm -rf /Applications/Perspec.app 99 | cp -R Perspec.app /Applications/Perspec.app 100 | 101 | 102 | .PHONY: clean 103 | clean: 104 | -rm -rf \ 105 | ~/.local/bin/perspec \ 106 | .stack-work \ 107 | imagemagick \ 108 | imagemagick.tar.gz \ 109 | Perspec.app \ 110 | -------------------------------------------------------------------------------- /package.yaml: -------------------------------------------------------------------------------- 1 | name: perspec 2 | version: 0.2.0.0 3 | github: feramhq/Perspec 4 | license-file: license 5 | author: Adrian Sieber 6 | maintainer: mail@adriansieber.com 7 | copyright: Adrian Sieber 8 | 9 | extra-source-files: 10 | - readme.md 11 | - changelog.md 12 | - cbits/tinyfiledialogs.h 13 | - cbits/simplecv.h 14 | - cbits/perspectivetransform.h 15 | 16 | synopsis: Scan documents and books with as little hardware as possible. 17 | category: Scan 18 | 19 | description: | 20 | Please see the readme on GitHub at 21 | 22 | language: GHC2021 23 | 24 | dependencies: 25 | - aeson 26 | - base >= 4.7 && < 5 27 | - bmp 28 | - brillo 29 | - brillo-juicy 30 | - brillo-rendering 31 | - bytestring 32 | - Color 33 | - directory 34 | - docopt 35 | - file-embed 36 | - filepath 37 | - hip 38 | - hmatrix 39 | - JuicyPixels 40 | - lens 41 | - linear 42 | - massiv 43 | - massiv-io 44 | - natural-sort 45 | - optparse-applicative 46 | - process 47 | - protolude 48 | - text 49 | - yaml 50 | 51 | default-extensions: 52 | - DuplicateRecordFields 53 | - LambdaCase 54 | - NoImplicitPrelude 55 | - OverloadedRecordDot 56 | - OverloadedStrings 57 | - RecordWildCards 58 | - TemplateHaskell 59 | 60 | ghc-options: 61 | - -Wall 62 | - -Wcompat 63 | - -Wincomplete-record-updates 64 | - -Wincomplete-uni-patterns 65 | - -Wredundant-constraints 66 | - -fno-warn-orphans 67 | 68 | library: 69 | source-dirs: source 70 | build-tools: c2hs 71 | c-sources: 72 | - cbits/tinyfiledialogs.c 73 | - cbits/simplecv.c 74 | - cbits/perspectivetransform.c 75 | include-dirs: cbits/ 76 | cc-options: -DHSFORCECONSOLE=0 77 | 78 | executables: 79 | perspec: 80 | main: Main.hs 81 | source-dirs: app 82 | ghc-options: 83 | - -threaded 84 | - -rtsopts 85 | - -with-rtsopts=-N 86 | dependencies: 87 | - perspec 88 | 89 | tests: 90 | perspec-test: 91 | main: Spec.hs 92 | source-dirs: test 93 | dependencies: 94 | - hspec 95 | - perspec 96 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |

5 | Icon 15 | 16 | Perspec 17 | 18 |

19 | 20 | App and workflow to perspectively correct images. 21 | For example whiteboards, document scans, or facades. 22 | 23 | 24 | 25 | - [App Workflow](#app-workflow) 26 | - [Installation](#installation) 27 | * [Prebuilt](#prebuilt) 28 | * [From Source](#from-source) 29 | - [Usage via CLI](#usage-via-cli) 30 | - [Photo Digitization Workflow](#photo-digitization-workflow) 31 | * [Additional Steps](#additional-steps) 32 | - [Features](#features) 33 | - [Algorithms](#algorithms) 34 | * [Perspective Transformation](#perspective-transformation) 35 | * [Grayscale Conversion](#grayscale-conversion) 36 | * [BW Conversion](#bw-conversion) 37 | * [Interpolation of Missing Parts](#interpolation-of-missing-parts) 38 | - [Technologies](#technologies) 39 | - [Related](#related) 40 | 41 | 42 | 43 | 44 | ## App Workflow 45 | 46 | Step | Description | Result 47 | -----|--------------------------------------------|------------------ 48 | 1 | Take photos | ![Original image][doc] 49 | 2 | Open Perspec app | ![Opened Perspec App][open] 50 | 3 | Drop the images onto the window | ![Dropped image][dropped] 51 | 4 | Mark the corners by clicking on them | ![Marked corners][corners] 52 | 5 | Click one of the save buttons (or [Enter]) | ![Corrected image][fixed] 53 | 54 | [doc]: images/doc.jpg 55 | [mark]: images/doc-marking.jpg 56 | [open]: images/perspec_opened.png 57 | [dropped]: images/perspec_image_dropped.png 58 | [corners]: images/perspec_marked_corners.png 59 | [fixed]: images/doc-fixed.jpg 60 | 61 | 62 | ## Installation 63 | 64 | **WARNING:** 65 | Perspec currently only works on macOS and Linux. 66 | Any help to make it work on 67 | Microsoft [(Ticket)](https://github.com/feramhq/Perspec/issues/21) 68 | would be greatly appreciated! 69 | 70 | 71 | ### Prebuilt 72 | 73 | You can get this (and previous) versions from 74 | [the releases page](https://github.com/feramhq/Perspec/releases). 75 | 76 | The current nightly version can be downloaded from 77 | https://github.com/feramhq/Perspec/actions. 78 | However, it's necessary to fix the file permissions after download: 79 | 80 | ```sh 81 | chmod +x \ 82 | ./Perspec.app/Contents/MacOS/Perspec \ 83 | ./Perspec.app/Contents/Resources/{perspec,script,imagemagick/bin/magick} 84 | ``` 85 | 86 | On macOS you can also install it via this [Homebrew](https://brew.sh) tap: 87 | 88 | ```sh 89 | brew install --cask ad-si/tap/perspec 90 | ``` 91 | 92 | 93 | ### From Source 94 | 95 | Build it from source with Haskell's 96 | [stack](https://docs.haskellstack.org/en/stable/install_and_upgrade/). 97 | 98 | Platypus, with 99 | [command line tools enabled](https://github.com/sveinbjornt/Platypus/blob/master/Documentation/Documentation.md#show-shell-command) 100 | , is required to build from source. 101 | 102 | ```sh 103 | git clone https://github.com/feramhq/Perspec 104 | cd Perspec 105 | make install 106 | ``` 107 | 108 | This copies the `Perspec.app` to your `/Applications` directory 109 | and makes the `perspec` command available on your path. 110 | You can then either drop images on the app window, 111 | or use it via the CLI like `perspec fix image.jpeg` 112 | 113 | 114 | ## Usage via CLI 115 | 116 | It's also possible to directly invoke Perspec via the CLI like so: 117 | 118 | ```sh 119 | /Applications/Perspec.app/Contents/Resources/perspec fix path/to/image.jpeg 120 | ``` 121 | 122 | You can also pass several images and they will all be opened 123 | one after another. 124 | This is very useful for batch correcting a large set of images. 125 | 126 | 127 | ## Photo Digitization Workflow 128 | 129 | 1. Take photos 130 | 1. Use camera app which lets you lock rotation (e.g. [OpenCamera]). 131 | Otherwise check out the guide below to fix rotation. 132 | 1. Use a sound activated camera to take photos simply 133 | by clicking your tongue or snipping your finger. E.g. with: 134 | - [Pluto Trigger] - Hardware device 135 | - [Magic Lantern] - 3rd party firmware for Canon 136 | - [iSoundCam] - Android app 137 | 1. Use `perspec rename` sub-command to fix order and names of scanned files. 138 | 1. Verify that 139 | - All pages were captured and have the correct filename 140 | - Images are sharp enough 141 | - Images have a high contrast 142 | - Images have correct orientation 143 | 1. For best image quality convert images optionally 144 | to a lossless format (e.g. `png`), 145 | apply rotations, and convert them to grayscale. 146 | Attention: Exclude the covers! 147 | ```sh 148 | mogrify -verbose -format png \ 149 | -auto-orient -colorspace gray photos/*.jpeg 150 | ``` 151 | 1. Use Perspec to crop images 152 | ```sh 153 | perspec fix photos/*.png 154 | ```` 155 | 156 | [iSoundCam]: http://www.cherry-software.com/isoundcam.html 157 | [Magic Lantern]: https://wiki.magiclantern.fm/pl:userguide?#audio_remoteshot 158 | [OpenCamera]: 159 | https://play.google.com/store/apps/details?id=net.sourceforge.opencamera 160 | [Pluto Trigger]: https://plutotrigger.com 161 | 162 | 163 | ### Additional Steps 164 | 165 | Improve colors with one of the following steps: 166 | 167 | 1. Normalize dynamic range: 168 | ```sh 169 | mogrify -verbose -normalize photos/*.png 170 | ``` 171 | 1. Convert to black and white: 172 | ```sh 173 | #! /usr/bin/env bash 174 | 175 | find . -iname "*.png" | \ 176 | while read -r file 177 | do 178 | magick \ 179 | -verbose \ 180 | "$file" \ 181 | \( +clone -blur 0x60 -brightness-contrast 40 \) \ 182 | -compose minus \ 183 | -composite \ 184 | -negate \ 185 | -auto-threshold otsu \ 186 | "$(basename "$file" ".png")"-fixed.png 187 | done 188 | ``` 189 | 190 | In order to rotate all photos to portrait mode you can use either 191 | ```sh 192 | mogrify -verbose -auto-orient -rotate "90>" photos/*.jpeg 193 | ``` 194 | or 195 | ```sh 196 | mogrify -verbose -auto-orient -rotate "-90>" photos/*.jpeg 197 | ``` 198 | 199 | 200 | ## Features 201 | 202 | - [x] Rescale image on viewport change 203 | - [x] Handle JPEG rotation 204 | - [x] Draw lines between corners to simplify guessing of clipped corners 205 | - [x] Bundle Imagemagick 206 | - [x] Better error if wrong file format is dropped (images/error-message.jpg) 207 | - [x] Center Perspec window on screen 208 | - [x] Drag'n'Drop for corner markers 209 | - [x] "Submit" button 210 | - [x] "Convert to Grayscale" button 211 | - [ ] Add support for custom output size (e.g. A4) 212 | - [ ] Manual rotation buttons 213 | - [ ] Zoom view for corners 214 | - [ ] Label corner markers 215 | 216 | 217 | ## Algorithms 218 | 219 | ### Perspective Transformation 220 | 221 | Once the corners are marked, the correction is equivalent to: 222 | 223 | ```sh 224 | magick \ 225 | images/example.jpg \ 226 | -distort Perspective \ 227 | '8,35 0,0 27,73 0,66 90,72 63,66 67,10 63,0' \ 228 | -crop 63x66+0+0 \ 229 | images/example-fixed.jpg 230 | ``` 231 | 232 | ### Grayscale Conversion 233 | 234 | Converts image to grayscale and normalizes the range of values afterwards. 235 | (Uses Imagemagick's `-colorspace gray -normalize`) 236 | 237 | 238 | ### BW Conversion 239 | 240 | Converts image to binary format with OTSU's method. 241 | (Uses Imagemagick's `-auto-threshold OTSU -monochrome`) 242 | 243 | 244 | ### Interpolation of Missing Parts 245 | 246 | Perspec automatically interpolates missing parts by using the closest pixel. 247 | (https://www.imagemagick.org/Usage/misc/#edge) 248 | 249 | 250 | ## Technologies 251 | 252 | - Core is written in [Haskell](https://haskell.org) 253 | - Perspective transformation are handled by [ImageMagick] 254 | - App bundle is created with [Platypus](https://sveinbjorn.org/platypus) 255 | 256 | [ImageMagick]: https://imagemagick.org 257 | 258 | 259 | ## Related 260 | 261 | - [Hasscan] - OpenCV document scanner in Haskell. 262 | 263 | [Hasscan]: https://github.com/mryndzionek/hasscan 264 | 265 | Check out [ad-si/awesome-scanning](https://github.com/ad-si/awesome-scanning) 266 | for an extensive list of related projects. 267 | -------------------------------------------------------------------------------- /scripts/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/scripts/.gitkeep -------------------------------------------------------------------------------- /source/Correct.hs: -------------------------------------------------------------------------------- 1 | module Correct where 2 | 3 | import Protolude as P ( 4 | Double, 5 | Fractional ((/)), 6 | Num (abs, negate, (*)), 7 | RealFrac (round), 8 | fromMaybe, 9 | (.), 10 | ) 11 | 12 | -- hip 13 | import Graphics.Image (Ix2 ((:.)), Sz (Sz), Sz2) 14 | 15 | -- hmatrix 16 | import Numeric.LinearAlgebra (linearSolve, linearSolveSVD, (!), (><)) 17 | 18 | -- lens 19 | import Control.Lens ((^.)) 20 | 21 | -- linear 22 | import Linear ( 23 | Additive ((^+^), (^-^)), 24 | M33, 25 | R1 (_x), 26 | R2 (_y), 27 | R3 (_z), 28 | R4 (_w), 29 | V2 (..), 30 | V3 (V3), 31 | V4 (..), 32 | ) 33 | 34 | 35 | determineSize :: V4 (V2 Double) -> Sz2 36 | determineSize (V4 c1 c2 c3 c4) = do 37 | let 38 | diagonalA = c3 ^-^ c1 39 | diagonalB = c4 ^-^ c2 40 | V2 width height = (abs diagonalA ^+^ abs diagonalB) / 2 41 | 42 | Sz (round height :. round width) 43 | 44 | {- FOURMOLU_DISABLE -} 45 | -- /* Calculates coefficients of perspective transformation 46 | -- * which maps (xi,yi) to (ui,vi), (i=1,2,3,4): 47 | -- * 48 | -- * c00*xi + c01*yi + c02 49 | -- * ui = --------------------- 50 | -- * c20*xi + c21*yi + c22 51 | -- * 52 | -- * c10*xi + c11*yi + c12 53 | -- * vi = --------------------- 54 | -- * c20*xi + c21*yi + c22 55 | -- * 56 | -- * Coefficients are calculated by solving linear system: 57 | -- * / x0 y0 1 0 0 0 -x0*u0 -y0*u0 \ /c00\ /u0\ 58 | -- * | x1 y1 1 0 0 0 -x1*u1 -y1*u1 | |c01| |u1| 59 | -- * | x2 y2 1 0 0 0 -x2*u2 -y2*u2 | |c02| |u2| 60 | -- * | x3 y3 1 0 0 0 -x3*u3 -y3*u3 |.|c10|=|u3|, 61 | -- * | 0 0 0 x0 y0 1 -x0*v0 -y0*v0 | |c11| |v0| 62 | -- * | 0 0 0 x1 y1 1 -x1*v1 -y1*v1 | |c12| |v1| 63 | -- * | 0 0 0 x2 y2 1 -x2*v2 -y2*v2 | |c20| |v2| 64 | -- * \ 0 0 0 x3 y3 1 -x3*v3 -y3*v3 / \c21/ \v3/ 65 | -- * 66 | -- * where: 67 | -- * cij - matrix coefficients, c22 = 1 68 | -- */ 69 | -- } 70 | calculatePerspectiveTransform :: V4 (V2 Double) -> V4 (V2 Double) -> M33 Double 71 | calculatePerspectiveTransform s d = 72 | let 73 | a = (8><8) 74 | [ s ^. _x . _x , s ^. _x . _y , 1 , 0 , 0 , 0, negate(s ^. _x . _x * d ^. _x . _x), negate(s ^. _x . _y * d ^. _x . _x) 75 | , s ^. _y . _x , s ^. _y . _y , 1 , 0 , 0 , 0, negate(s ^. _y . _x * d ^. _y . _x), negate(s ^. _y . _y * d ^. _y . _x) 76 | , s ^. _z . _x , s ^. _z . _y , 1 , 0 , 0 , 0, negate(s ^. _z . _x * d ^. _z . _x), negate(s ^. _z . _y * d ^. _z . _x) 77 | , s ^. _w . _x , s ^. _w . _y , 1 , 0 , 0 , 0, negate(s ^. _w . _x * d ^. _w . _x), negate(s ^. _w . _y * d ^. _w . _x) 78 | , 0 , 0 , 0 , s ^. _x . _x , s ^. _x . _y , 1, negate(s ^. _x . _x * d ^. _x . _y), negate(s ^. _x . _y * d ^. _x . _y) 79 | , 0 , 0 , 0 , s ^. _y . _x , s ^. _y . _y , 1, negate(s ^. _y . _x * d ^. _y . _y), negate(s ^. _y . _y * d ^. _y . _y) 80 | , 0 , 0 , 0 , s ^. _z . _x , s ^. _z . _y , 1, negate(s ^. _z . _x * d ^. _z . _y), negate(s ^. _z . _y * d ^. _z . _y) 81 | , 0 , 0 , 0 , s ^. _w . _x , s ^. _w . _y , 1, negate(s ^. _w . _x * d ^. _w . _y), negate(s ^. _w . _y * d ^. _w . _y) 82 | ] 83 | b = (8><1) 84 | [ d ^. _x . _x 85 | , d ^. _y . _x 86 | , d ^. _z . _x 87 | , d ^. _w . _x 88 | , d ^. _x . _y 89 | , d ^. _y . _y 90 | , d ^. _z . _y 91 | , d ^. _w . _y 92 | ] 93 | m = fromMaybe (linearSolveSVD a b) (linearSolve a b) 94 | in V3 95 | (V3 (m ! 0 ! 0) (m ! 1 ! 0) (m ! 2 ! 0)) 96 | (V3 (m ! 3 ! 0) (m ! 4 ! 0) (m ! 5 ! 0)) 97 | (V3 (m ! 6 ! 0) (m ! 7 ! 0) 1) 98 | {- FOURMOLU_ENABLE -} 99 | -------------------------------------------------------------------------------- /source/Home.hs: -------------------------------------------------------------------------------- 1 | module Home where 2 | 3 | import Protolude ( 4 | Applicative (pure), 5 | Bool (..), 6 | Fractional ((/)), 7 | IO, 8 | Int, 9 | Maybe (Just, Nothing), 10 | Num, 11 | putText, 12 | (<&>), 13 | ) 14 | 15 | import Brillo.Interface.IO.Game as Gl ( 16 | Event (..), 17 | Key (MouseButton), 18 | KeyState (Down), 19 | MouseButton (LeftButton), 20 | ) 21 | import Data.Text qualified as T 22 | 23 | import TinyFileDialogs (openFileDialog) 24 | import Types (AppState (..), ImageData (..), View (ImageView)) 25 | import Utils (isInRect, loadFileIntoState) 26 | 27 | 28 | ticksPerSecond :: Int 29 | ticksPerSecond = 10 30 | 31 | 32 | data Message 33 | = ClickSelectFiles 34 | | OpenFileDialog 35 | 36 | 37 | handleMsg :: Message -> AppState -> IO AppState 38 | handleMsg msg appState = 39 | case msg of 40 | ClickSelectFiles -> do 41 | putText "ClickSelectFiles" 42 | pure appState 43 | OpenFileDialog -> do 44 | selectedFiles <- 45 | openFileDialog 46 | {- Title -} "Open File" 47 | {- Default path -} "/" 48 | {- File patterns -} ["*.jpeg", ".jpg", ".png"] 49 | {- Filter description -} "Image files" 50 | {- Allow multiple selects -} True 51 | 52 | case selectedFiles of 53 | Just files -> do 54 | let newState = 55 | appState 56 | { currentView = ImageView 57 | , images = 58 | files <&> \filePath -> 59 | ImageToLoad{filePath = T.unpack filePath} 60 | } 61 | loadFileIntoState newState 62 | Nothing -> do 63 | putText "No file selected" 64 | pure appState 65 | 66 | 67 | handleHomeEvent :: Event -> AppState -> IO AppState 68 | handleHomeEvent event appState = 69 | case event of 70 | EventKey (MouseButton Gl.LeftButton) Gl.Down _ clickedPoint -> do 71 | let 72 | fileSelectBtnWidth :: (Num a) => a 73 | fileSelectBtnWidth = 120 74 | 75 | fileSelectBtnHeight :: (Num a) => a 76 | fileSelectBtnHeight = 40 77 | 78 | fileSelectBtnRect = 79 | ( -(fileSelectBtnWidth / 2) 80 | , -(fileSelectBtnHeight / 2) 81 | , fileSelectBtnWidth / 2 82 | , fileSelectBtnHeight / 2 83 | ) 84 | fileSelectBtnWasClicked = clickedPoint `isInRect` fileSelectBtnRect 85 | 86 | if fileSelectBtnWasClicked 87 | then handleMsg OpenFileDialog appState 88 | else pure appState 89 | _ -> pure appState 90 | -------------------------------------------------------------------------------- /source/Lib.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DataKinds #-} 2 | 3 | module Lib where 4 | 5 | import Protolude ( 6 | Applicative (pure), 7 | Bool (..), 8 | Double, 9 | Either (Left, Right), 10 | Eq ((==)), 11 | FilePath, 12 | Float, 13 | Floating (sqrt), 14 | Fractional ((/)), 15 | Functor (fmap), 16 | IO, 17 | IOException, 18 | Int, 19 | Maybe (Just, Nothing), 20 | Monoid (mempty), 21 | Num ((*), (+), (-)), 22 | Ord (max, min, (<), (>)), 23 | Semigroup ((<>)), 24 | Text, 25 | const, 26 | either, 27 | exitSuccess, 28 | flip, 29 | fromIntegral, 30 | fromMaybe, 31 | fromRight, 32 | fst, 33 | not, 34 | putText, 35 | realToFrac, 36 | show, 37 | snd, 38 | try, 39 | when, 40 | ($), 41 | (&), 42 | (&&), 43 | (++), 44 | (<$>), 45 | (<&>), 46 | ) 47 | 48 | import Data.ByteString.Lazy qualified as BL 49 | import Data.FileEmbed (embedFile) 50 | import Data.List as DL (elemIndex, minimum) 51 | import Data.Text qualified as T 52 | import Foreign (castForeignPtr, newForeignPtr_, withForeignPtr) 53 | import Foreign.Marshal.Alloc (free) 54 | import Foreign.Marshal.Utils (new) 55 | import Foreign.Ptr (castPtr) 56 | import Foreign.Storable (peek) 57 | import GHC.Float (float2Double, int2Double) 58 | import Protolude qualified as P 59 | import System.Directory (getCurrentDirectory) 60 | import System.Environment (getEnv, setEnv) 61 | import System.FilePath (replaceExtension) 62 | import System.Info (os) 63 | import System.Process (callProcess, spawnProcess) 64 | 65 | import Brillo ( 66 | Display (InWindow), 67 | Picture ( 68 | Bitmap, 69 | Pictures, 70 | Scale, 71 | ThickArc, 72 | ThickCircle, 73 | ThickLineSmooth, 74 | Translate 75 | ), 76 | Point, 77 | bitmapOfBMP, 78 | black, 79 | color, 80 | greyN, 81 | lineLoop, 82 | makeColor, 83 | pictures, 84 | rectangleSolid, 85 | ) 86 | import Brillo.Interface.Environment (getScreenSize) 87 | import Brillo.Interface.IO.Game as Gl ( 88 | Color, 89 | Event (..), 90 | Key (MouseButton, SpecialKey), 91 | KeyState (Down, Up), 92 | MouseButton (LeftButton), 93 | SpecialKey (KeyEnter, KeyEsc), 94 | playIO, 95 | ) 96 | import Brillo.Rendering (BitmapData (..)) 97 | import Codec.BMP (parseBMP) 98 | import Codec.Picture ( 99 | DynamicImage (ImageRGBA8), 100 | imageFromUnsafePtr, 101 | savePngImage, 102 | ) 103 | import Graphics.Image ( 104 | Alpha, 105 | Bilinear (Bilinear), 106 | Image, 107 | Interpolation (interpolate), 108 | Ix2 (..), 109 | Linearity (Linear), 110 | SRGB, 111 | Sz (Sz), 112 | readImageRGBA, 113 | transform, 114 | writeImage, 115 | ) 116 | import Linear (M33, V2 (V2), V3 (V3), V4 (V4), (!*)) 117 | 118 | import Correct (calculatePerspectiveTransform, determineSize) 119 | import Home (handleHomeEvent) 120 | import SimpleCV (Corners (..), applyMatrix3x3, prettyShowCorners, prettyShowMatrix3x3) 121 | import SimpleCV qualified as SCV 122 | import Types ( 123 | AppState (..), 124 | Config (transformBackendFlag), 125 | ConversionMode (CallConversion, SpawnConversion), 126 | Corner, 127 | CornersTup, 128 | ExportMode (..), 129 | ImageData (..), 130 | ProjMap, 131 | TransformBackend (..), 132 | UiComponent (Button, Select, text), 133 | View (..), 134 | initialState, 135 | ) 136 | import Utils ( 137 | calcInitWindowPos, 138 | calculateSizes, 139 | getCorners, 140 | getWordSprite, 141 | loadFileIntoState, 142 | loadImage, 143 | prettyPrintArray, 144 | ) 145 | 146 | 147 | -- | This is replaced with valid licenses during CI build 148 | licenses :: [Text] 149 | licenses = [] 150 | 151 | 152 | -- | Radius of the circles to mark the corners of the selection 153 | cornCircRadius :: Float 154 | cornCircRadius = 6 155 | 156 | 157 | -- | Border thickness of the circles to mark the corners of the selection 158 | cornCircThickness :: Float 159 | cornCircThickness = 4 160 | 161 | 162 | numGridLines :: Int 163 | numGridLines = 7 164 | 165 | 166 | gridColor :: Gl.Color 167 | gridColor = makeColor 0.2 1 0.7 0.6 168 | 169 | 170 | sidebarPaddingTop :: Int 171 | sidebarPaddingTop = 50 172 | 173 | 174 | sidebarGridHeight :: Int 175 | sidebarGridHeight = 40 176 | 177 | 178 | ticksPerSecond :: Int 179 | ticksPerSecond = 10 180 | 181 | 182 | bannerTime :: Float 183 | bannerTime = 10 -- seconds 184 | 185 | 186 | bannerImage :: Picture 187 | bannerImage = 188 | either 189 | (const mempty) 190 | bitmapOfBMP 191 | (parseBMP (BL.fromStrict $(embedFile "images/banner.bmp"))) 192 | 193 | 194 | appStateToWindow :: (Int, Int) -> AppState -> Display 195 | appStateToWindow screenSize appState = do 196 | let 197 | appSize = (appState.appWidth, appState.appHeight) 198 | windowPos = calcInitWindowPos screenSize appSize 199 | 200 | case appState.images of 201 | [] -> InWindow "Perspec" appSize (0, 0) 202 | image : _otherImages -> do 203 | case appState.currentView of 204 | HomeView -> InWindow "Perspec - Select a file" appSize windowPos 205 | ImageView -> do 206 | InWindow 207 | ( "Perspec - " 208 | <> case image of 209 | ImageToLoad{filePath} -> filePath 210 | ImageData{inputPath} -> inputPath 211 | <> if appState.isRegistered 212 | then mempty 213 | else " - ⚠️ NOT REGISTERED" 214 | ) 215 | appSize 216 | windowPos 217 | BannerView -> 218 | InWindow "Perspec - Banner" (800, 600) (10, 10) 219 | 220 | 221 | stepWorld :: Float -> AppState -> IO AppState 222 | stepWorld _ appState = 223 | if not appState.isRegistered 224 | && ( fromIntegral appState.tickCounter 225 | < (bannerTime * fromIntegral ticksPerSecond) 226 | ) 227 | then pure appState{tickCounter = appState.tickCounter + 1} 228 | else pure appState{bannerIsVisible = False} 229 | 230 | 231 | drawCorner :: Point -> Picture 232 | drawCorner (x, y) = 233 | Translate 234 | x 235 | y 236 | (color gridColor $ ThickCircle cornCircRadius cornCircThickness) 237 | 238 | 239 | gridLineThickness :: Float 240 | gridLineThickness = 2 241 | 242 | 243 | -- TODO: Use thick lines for line-loop 244 | drawEdges :: [Point] -> Picture 245 | drawEdges points = 246 | color gridColor $ lineLoop points 247 | 248 | 249 | drawGrid :: [Point] -> Picture 250 | drawGrid [p1, p2, p3, p4] = 251 | let 252 | numSegments = fromIntegral $ numGridLines + 1 253 | 254 | getLinePoint :: Int -> Int -> Point -> Point -> Point 255 | getLinePoint sgmnts idx pA pB = 256 | let 257 | fraction = 1 / fromIntegral sgmnts 258 | in 259 | ( fst pA + (fst pB - fst pA) * (fromIntegral idx * fraction) 260 | , snd pA + (snd pB - snd pA) * (fromIntegral idx * fraction) 261 | ) 262 | 263 | getGridLineVert num = 264 | color gridColor $ 265 | ThickLineSmooth 266 | [ getLinePoint numSegments num p1 p2 267 | , getLinePoint numSegments num p4 p3 268 | ] 269 | gridLineThickness 270 | 271 | getGridLineHor num = 272 | color gridColor $ 273 | ThickLineSmooth 274 | [ getLinePoint numSegments num p1 p4 275 | , getLinePoint numSegments num p2 p3 276 | ] 277 | gridLineThickness 278 | in 279 | Pictures $ 280 | ([1 .. numGridLines] <&> getGridLineVert) 281 | <> ([1 .. numGridLines] <&> getGridLineHor) 282 | drawGrid _ = mempty 283 | 284 | 285 | drawSidebar :: Int -> Int -> Int -> Picture 286 | drawSidebar appWidth appHeight width = 287 | Translate 288 | ( (fromIntegral appWidth / 2.0) 289 | - (fromIntegral width / 2.0) 290 | ) 291 | 0 292 | ( color (greyN 0.1) $ 293 | rectangleSolid 294 | (fromIntegral width) 295 | (fromIntegral appHeight) 296 | ) 297 | 298 | 299 | drawButton :: (Int, Int) -> Int -> Int -> Text -> (Int, Int) -> Picture 300 | drawButton 301 | (appWidth, appHeight) 302 | sidebarWidth 303 | topOffset 304 | btnText 305 | (btnWidth, btnHeight) = 306 | Translate 307 | ( (fromIntegral appWidth / 2.0) 308 | - (fromIntegral btnWidth / 2.0) 309 | - ((fromIntegral sidebarWidth - fromIntegral btnWidth) / 2.0) 310 | ) 311 | ( (fromIntegral appHeight / 2.0) 312 | - fromIntegral topOffset 313 | - (fromIntegral btnHeight / 2.0) 314 | ) 315 | $ pictures 316 | [ color (greyN 0.2) $ 317 | rectangleSolid 318 | (fromIntegral btnWidth) 319 | (fromIntegral btnHeight) 320 | , Translate 0 (-4) $ getWordSprite btnText 321 | ] 322 | 323 | 324 | drawUiComponent :: AppState -> UiComponent -> Int -> Picture 325 | drawUiComponent appState uiComponent componentIndex = 326 | case uiComponent of 327 | Button btnText btnWidth btnHeight _ -> 328 | drawButton 329 | (appState.appWidth, appState.appHeight) 330 | appState.sidebarWidth 331 | (sidebarPaddingTop + (componentIndex * sidebarGridHeight)) 332 | btnText 333 | (btnWidth, btnHeight) 334 | Select -> mempty 335 | 336 | 337 | -- | Render the app state to a picture. 338 | makePicture :: AppState -> IO Picture 339 | makePicture appState = 340 | case appState.currentView of 341 | HomeView -> do 342 | let 343 | fileSelectBtnWidth :: (Num a) => a 344 | fileSelectBtnWidth = 120 345 | 346 | fileSelectBtnHeight :: (Num a) => a 347 | fileSelectBtnHeight = 40 348 | 349 | uiElements = 350 | pictures 351 | [ color (greyN 0.2) $ 352 | rectangleSolid fileSelectBtnWidth fileSelectBtnHeight 353 | , Translate 0 (-4) $ getWordSprite "Select Files" 354 | ] 355 | pure uiElements 356 | ImageView -> do 357 | case appState.images of 358 | [] -> pure mempty 359 | image : _otherImages -> do 360 | let 361 | appWidthInteg = fromIntegral appState.appWidth 362 | sidebarWidthInteg = fromIntegral appState.sidebarWidth 363 | 364 | pure $ 365 | Pictures $ 366 | ( ( [ Scale 367 | appState.scaleFactor 368 | appState.scaleFactor 369 | ( case image of 370 | ImageToLoad{} -> mempty 371 | ImageData{content} -> content 372 | ) 373 | , appState.corners & drawEdges 374 | , appState.corners & drawGrid 375 | ] 376 | <> (appState.corners <&> drawCorner) 377 | ) 378 | <&> Translate (-(sidebarWidthInteg / 2.0)) 0 379 | ) 380 | <> [ drawSidebar 381 | appWidthInteg 382 | appState.appHeight 383 | appState.sidebarWidth 384 | ] 385 | <> P.zipWith 386 | (drawUiComponent appState) 387 | appState.uiComponents 388 | [0 ..] 389 | <> [ if appState.bannerIsVisible 390 | then Scale 0.5 0.5 bannerImage 391 | else mempty 392 | , if appState.bannerIsVisible 393 | then 394 | Translate 300 (-250) $ 395 | Scale 0.2 0.2 $ 396 | ThickArc 397 | 0 -- Start angle 398 | -- End angle 399 | ( ( fromIntegral appState.tickCounter 400 | / (bannerTime * fromIntegral ticksPerSecond) 401 | ) 402 | * 360 403 | ) 404 | 50 -- Radius 405 | 100 -- Thickness 406 | -- \$ 407 | -- - 408 | else mempty 409 | ] 410 | BannerView -> pure $ Pictures [] 411 | 412 | 413 | replaceElemAtIndex :: Int -> a -> [a] -> [a] 414 | replaceElemAtIndex theIndex newElem (x : xs) = 415 | if theIndex == 0 416 | then newElem : xs 417 | else x : replaceElemAtIndex (theIndex - 1) newElem xs 418 | replaceElemAtIndex _ _ [] = [] 419 | 420 | 421 | calcDistance :: Point -> Point -> Float 422 | calcDistance (x1, y1) (x2, y2) = 423 | let 424 | xDelta = x1 - x2 425 | yDelta = y1 - y2 426 | in 427 | sqrt (xDelta * xDelta + yDelta * yDelta) 428 | 429 | 430 | -- | Get index of closest point 431 | getIndexClosest :: [Point] -> Point -> Int 432 | getIndexClosest points point = 433 | let 434 | distances = fmap (calcDistance point) points 435 | minDistance = DL.minimum distances 436 | in 437 | fromMaybe 0 (DL.elemIndex minDistance distances) 438 | 439 | 440 | addCorner :: AppState -> Corner -> AppState 441 | addCorner appState newCorner = 442 | let 443 | theCorners = corners appState 444 | newCorners = 445 | if P.length theCorners < 4 446 | then newCorner : theCorners 447 | else 448 | replaceElemAtIndex 449 | (getIndexClosest theCorners newCorner) 450 | newCorner 451 | theCorners 452 | in 453 | appState{corners = newCorners} 454 | 455 | 456 | -- TODO: Use correct algorithm as described in the readme 457 | getTargetShape :: CornersTup -> (Float, Float) 458 | getTargetShape (topLeft, topRight, btmRight, btmLeft) = 459 | let 460 | topEdgeLength = calcDistance topLeft topRight 461 | bottomEdgeLength = calcDistance btmLeft btmRight 462 | width = (topEdgeLength + bottomEdgeLength) / 2 463 | 464 | leftEdgeLength = calcDistance topLeft btmLeft 465 | rightEdgeLength = calcDistance topRight btmRight 466 | height = (leftEdgeLength + rightEdgeLength) / 2 467 | in 468 | (width, height) 469 | 470 | 471 | toQuadTuple :: [a] -> Either Text (a, a, a, a) 472 | toQuadTuple [tl, tr, br, bl] = Right (tl, tr, br, bl) 473 | toQuadTuple _ = Left "The list must contain 4 values" 474 | 475 | 476 | {-| Assuming coordinate system starts top left 477 | | 'getProjectionMap clickShape targetShape' 478 | -} 479 | getProjectionMap :: CornersTup -> (Float, Float) -> ProjMap 480 | getProjectionMap (tl, tr, br, bl) (wdth, hgt) = 481 | ( (tl, (0, 0)) 482 | , (tr, (wdth, 0)) 483 | , (br, (wdth, hgt)) 484 | , (bl, (0, hgt)) 485 | ) 486 | 487 | 488 | -- | Accommodate ImageMagick's counter-clockwise direction 489 | toCounterClock :: (a, a, a, a) -> (a, a, a, a) 490 | toCounterClock (tl, tr, br, bl) = (tl, bl, br, tr) 491 | 492 | 493 | appCoordToImgCoord :: AppState -> Point -> Point 494 | appCoordToImgCoord appState point = 495 | ( fst point + (fromIntegral appState.sidebarWidth / 2.0) 496 | , snd point 497 | ) 498 | 499 | 500 | checkSidebarRectHit 501 | :: (Int, Int) -> Int -> Int -> (Int, Int) -> (Float, Float) -> Bool 502 | checkSidebarRectHit 503 | (appW, appH) 504 | sidebarW 505 | topOffset 506 | (rectW, rectH) 507 | (hitX, hitY) = 508 | let 509 | minX = 510 | (fromIntegral appW / 2.0) 511 | - fromIntegral rectW 512 | - ((fromIntegral sidebarW - fromIntegral rectW) / 2.0) 513 | maxX = minX + fromIntegral rectW 514 | 515 | minY = 516 | (fromIntegral appH / 2.0) 517 | - fromIntegral topOffset 518 | - fromIntegral rectH 519 | maxY = minY + fromIntegral rectH 520 | in 521 | hitX > minX 522 | && hitX < maxX 523 | && hitY > minY 524 | && hitY < maxY 525 | 526 | 527 | submitSelection :: AppState -> ExportMode -> IO AppState 528 | submitSelection appState exportMode = do 529 | case appState.images of 530 | [] -> pure appState 531 | image : otherImages -> do 532 | let 533 | cornersTrans = getCorners appState 534 | cornerTuple = 535 | fromRight 536 | ((0, 0), (0, 0), (0, 0), (0, 0)) 537 | (toQuadTuple cornersTrans) 538 | targetShape = getTargetShape cornerTuple 539 | projectionMapNorm = 540 | toCounterClock $ 541 | getProjectionMap cornerTuple targetShape 542 | 543 | putText $ "Target shape: " <> show targetShape 544 | putText $ "Marked corners: " <> show cornerTuple 545 | 546 | let 547 | convertArgs = 548 | getConvertArgs 549 | image.inputPath 550 | image.outputPath 551 | projectionMapNorm 552 | targetShape 553 | exportMode 554 | 555 | when (appState.transformBackend == ImageMagickBackend) $ do 556 | putText $ 557 | "Arguments for convert command:\n" 558 | <> T.unlines convertArgs 559 | 560 | correctAndWrite 561 | appState.transformBackend 562 | image.inputPath 563 | image.outputPath 564 | projectionMapNorm 565 | exportMode 566 | convertArgs 567 | 568 | if P.null otherImages 569 | then exitSuccess 570 | else loadFileIntoState appState{images = otherImages} 571 | 572 | 573 | handleImageViewEvent :: Event -> AppState -> IO AppState 574 | handleImageViewEvent event appState = 575 | case event of 576 | EventKey (MouseButton LeftButton) Gl.Down _ clickedPoint -> do 577 | -- Check if a UiComponent was clicked 578 | let 579 | clickedComponent = 580 | P.find 581 | ( \(component, componentIndex) -> case component of 582 | Button _ width height _ -> 583 | checkSidebarRectHit 584 | (appState.appWidth, appState.appHeight) 585 | appState.sidebarWidth 586 | (sidebarPaddingTop + (componentIndex * sidebarGridHeight)) 587 | (width, height) 588 | clickedPoint 589 | _ -> False 590 | ) 591 | (P.zip appState.uiComponents [0 ..]) 592 | <&> fst 593 | 594 | case clickedComponent of 595 | Just (Button{text = "Save"}) -> 596 | submitSelection appState UnmodifiedExport 597 | Just (Button{text = "Save Gray"}) -> 598 | submitSelection appState GrayscaleExport 599 | Just (Button{text = "Save BW"}) -> 600 | submitSelection appState BlackWhiteExport 601 | Just (Button{text = "Save BW Smooth"}) -> 602 | submitSelection appState BlackWhiteSmoothExport 603 | _ -> do 604 | let 605 | point = appCoordToImgCoord appState clickedPoint 606 | clickedCorner = 607 | P.find 608 | ( \corner -> 609 | calcDistance point corner 610 | < (cornCircRadius + cornCircThickness) 611 | ) 612 | appState.corners 613 | 614 | pure $ case clickedCorner of 615 | Nothing -> 616 | appState 617 | & flip addCorner point 618 | & (\state_ -> state_{cornerDragged = Just point}) 619 | Just cornerPoint -> 620 | appState{cornerDragged = Just cornerPoint} 621 | EventKey (MouseButton LeftButton) Gl.Up _ _ -> do 622 | pure $ appState{cornerDragged = Nothing} 623 | EventMotion newPoint -> do 624 | let 625 | point = appCoordToImgCoord appState newPoint 626 | 627 | pure $ case appState.cornerDragged of 628 | Nothing -> appState 629 | Just cornerPoint -> 630 | let 631 | cornerIndexMb = 632 | elemIndex 633 | cornerPoint 634 | appState.corners 635 | in 636 | case cornerIndexMb of 637 | Nothing -> appState 638 | Just cornerIndex -> 639 | appState 640 | { corners = 641 | replaceElemAtIndex 642 | cornerIndex 643 | point 644 | appState.corners 645 | , cornerDragged = Just point 646 | } 647 | EventKey (SpecialKey KeyEnter) Gl.Down _ _ -> 648 | submitSelection appState UnmodifiedExport 649 | EventKey (SpecialKey KeyEsc) Gl.Down _ _ -> do 650 | pure $ appState{corners = []} 651 | EventResize (windowWidth, windowHeight) -> do 652 | pure $ 653 | calculateSizes $ 654 | appState 655 | { appWidth = windowWidth 656 | , appHeight = windowHeight 657 | } 658 | _ -> 659 | pure appState 660 | 661 | 662 | handleEvent :: Event -> AppState -> IO AppState 663 | handleEvent event appState = 664 | case appState.currentView of 665 | HomeView -> handleHomeEvent event appState 666 | ImageView -> handleImageViewEvent event appState 667 | BannerView -> pure appState 668 | 669 | 670 | -- FIXME: Don't rely on show implementation 671 | showProjectionMap :: ProjMap -> Text 672 | showProjectionMap pMap = 673 | pMap 674 | & show 675 | & T.replace "),(" " " 676 | & T.replace "(" "" 677 | & T.replace ")" "" 678 | 679 | 680 | fixOutputPath :: ExportMode -> FilePath -> Text 681 | fixOutputPath exportMode outPath = 682 | case exportMode of 683 | BlackWhiteExport -> T.pack $ replaceExtension outPath "png" 684 | _ -> T.pack outPath 685 | 686 | 687 | getConvertArgs 688 | :: FilePath -> FilePath -> ProjMap -> (Float, Float) -> ExportMode -> [Text] 689 | getConvertArgs inPath outPath projMap shape exportMode = 690 | [ T.pack inPath 691 | , "-auto-orient" 692 | , "-define" 693 | , "distort:viewport=" 694 | <> show (fst shape) 695 | <> "x" 696 | <> show (snd shape) 697 | <> "+0+0" 698 | , -- TODO: Add flag to support this 699 | -- Use interpolated lookup instead of area resampling 700 | -- https://www.imagemagick.org/Usage/distorts/#area_vs_super 701 | -- , "-filter", "point" 702 | 703 | -- Prevent interpolation of unused pixels and avoid adding alpha channel 704 | "-virtual-pixel" 705 | , "black" 706 | , -- TODO: Add flag to support switching 707 | -- , "-virtual-pixel", "Edge" -- default 708 | -- , "-virtual-pixel", "Dither" 709 | -- , "-virtual-pixel", "Random" 710 | -- TODO: Implement more sophisticated one upstream in Imagemagick 711 | 712 | "-distort" 713 | , "Perspective" 714 | , showProjectionMap projMap 715 | ] 716 | <> case exportMode of 717 | UnmodifiedExport -> [] 718 | GrayscaleExport -> ["-colorspace", "gray", "-normalize"] 719 | BlackWhiteExport -> ["-auto-threshold", "OTSU", "-monochrome"] 720 | -- TODO: Use the correct algorithm as seen in 721 | -- https://github.com/ad-si/dotfiles/blob/master/bin/level 722 | BlackWhiteSmoothExport -> ["-auto-threshold", "OTSU", "-monochrome"] 723 | <> [ "+repage" 724 | , fixOutputPath exportMode outPath 725 | ] 726 | 727 | 728 | correctAndWrite 729 | :: TransformBackend 730 | -> FilePath 731 | -> FilePath 732 | -> ProjMap 733 | -> ExportMode 734 | -> [Text] 735 | -> IO () 736 | correctAndWrite transformBackend inPath outPath ((bl, _), (tl, _), (tr, _), (br, _)) exportMode args = do 737 | currentDir <- getCurrentDirectory 738 | 739 | case transformBackend of 740 | ImageMagickBackend -> do 741 | P.putText "ℹ️ Use ImageMagick backend" 742 | let 743 | conversionMode = CallConversion 744 | magickBin = case os of 745 | "darwin" -> currentDir ++ "/imagemagick/bin/magick" 746 | "mingw32" -> "TODO_implement" 747 | _ -> "TODO_bundle_imagemagick" 748 | 749 | when (os == "darwin") $ do 750 | setEnv "MAGICK_HOME" (currentDir ++ "/imagemagick") 751 | setEnv "DYLD_LIBRARY_PATH" (currentDir ++ "/imagemagick/lib") 752 | 753 | let 754 | argsNorm = args <&> T.unpack 755 | successMessage = 756 | "✅ Successfully saved converted image at " 757 | <> fixOutputPath exportMode outPath 758 | 759 | -- TODO: Add CLI flag to switch between them 760 | case conversionMode of 761 | CallConversion -> do 762 | resultBundled <- try $ callProcess magickBin argsNorm 763 | 764 | case resultBundled of 765 | Right _ -> putText successMessage 766 | Left (errLocal :: IOException) -> do 767 | P.putErrLn $ P.displayException errLocal 768 | putText "Use global installation of ImageMagick" 769 | 770 | -- Add more possible installation paths to PATH 771 | path <- getEnv "PATH" 772 | setEnv "PATH" $ 773 | path 774 | <> ":" 775 | <> P.intercalate 776 | ":" 777 | [ "/usr/local/bin" 778 | , "/usr/local/sbin" 779 | , "/opt/homebrew/bin" 780 | ] 781 | 782 | resultMagick <- try $ callProcess "magick" argsNorm 783 | case resultMagick of 784 | Right _ -> putText successMessage 785 | Left (error :: IOException) -> do 786 | P.putErrLn $ P.displayException error 787 | putText $ 788 | "⚠️ Please install ImageMagick first: " 789 | <> "https://imagemagick.org/script/download.php" 790 | SpawnConversion -> do 791 | _ <- spawnProcess magickBin (fmap T.unpack args) 792 | putText "✅ Successfully initiated conversion" 793 | -- 794 | HipBackend -> do 795 | P.putText "ℹ️ Use Hip backend" 796 | uncorrected <- readImageRGBA inPath 797 | 798 | let 799 | cornersClockwiseFromTopLeft :: V4 (V2 Double) 800 | cornersClockwiseFromTopLeft = do 801 | let 802 | toV2 :: (Float, Float) -> V2 Double 803 | toV2 (x, y) = realToFrac <$> V2 x y 804 | 805 | -- TODO: Not clear why order must be reversed here 806 | V4 (toV2 bl) (toV2 br) (toV2 tr) (toV2 tl) 807 | 808 | correctionTransform :: M33 Double 809 | correctionTransform = 810 | calculatePerspectiveTransform 811 | ( fmap fromIntegral 812 | <$> V4 813 | (V2 0 0) 814 | (V2 width 0) 815 | (V2 width height) 816 | (V2 0 height) 817 | ) 818 | cornersClockwiseFromTopLeft 819 | 820 | outputSize :: Sz Ix2 821 | outputSize@(Sz (height :. width)) = 822 | determineSize cornersClockwiseFromTopLeft 823 | 824 | corrected :: Image (Alpha (SRGB 'Linear)) Double 825 | corrected = 826 | uncorrected 827 | & transform 828 | (outputSize,) 829 | ( \(Sz (sourceHeight :. sourceWidth)) getPixel (Ix2 irow icol) -> do 830 | let 831 | V3 colCrd rowCrd p = 832 | correctionTransform !* V3 (fromIntegral icol) (fromIntegral irow) 1 833 | colCrd' = 834 | max 0 (min (colCrd / p) (fromIntegral $ sourceWidth - 1)) 835 | rowCrd' = 836 | max 0 (min (rowCrd / p) (fromIntegral $ sourceHeight - 1)) 837 | 838 | interpolate Bilinear getPixel (rowCrd', colCrd') 839 | ) 840 | 841 | case exportMode of 842 | UnmodifiedExport -> writeImage outPath corrected 843 | GrayscaleExport -> pure () 844 | BlackWhiteExport -> pure () 845 | BlackWhiteSmoothExport -> pure () 846 | -- 847 | SimpleCVBackend -> do 848 | P.putText "ℹ️ Use SimpleCV backend" 849 | 850 | let 851 | cornersClockwiseFromTopLeft :: V4 (V2 Double) 852 | cornersClockwiseFromTopLeft = do 853 | let 854 | toV2 :: (Float, Float) -> V2 Double 855 | toV2 (x, y) = realToFrac <$> V2 x y 856 | 857 | -- TODO: Not clear why order must be reversed here 858 | V4 (toV2 bl) (toV2 br) (toV2 tr) (toV2 tl) 859 | 860 | Sz (height :. width) = 861 | determineSize cornersClockwiseFromTopLeft 862 | 863 | -- TODO: Not clear why order must be reversed here 864 | srcCorners :: Corners 865 | srcCorners = 866 | Corners 867 | { tl_x = float2Double $ fst bl 868 | , tl_y = float2Double $ snd bl 869 | , tr_x = float2Double $ fst br 870 | , tr_y = float2Double $ snd br 871 | , br_x = float2Double $ fst tr 872 | , br_y = float2Double $ snd tr 873 | , bl_x = float2Double $ fst tl 874 | , bl_y = float2Double $ snd tl 875 | } 876 | 877 | dstCorners :: Corners 878 | dstCorners = 879 | Corners 880 | { tl_x = 0 881 | , tl_y = 0 882 | , tr_x = int2Double width 883 | , tr_y = 0 884 | , br_x = int2Double width 885 | , br_y = int2Double height 886 | , bl_x = 0 887 | , bl_y = int2Double height 888 | } 889 | 890 | putText "\nSource Corners:" 891 | putText $ prettyShowCorners srcCorners 892 | 893 | putText "\nDestination Corners:" 894 | putText $ dstCorners & prettyShowCorners & T.replace ".0" "" 895 | 896 | srcCornersPtr <- new srcCorners 897 | dstCornersPtr <- new dstCorners 898 | transMatPtr <- 899 | SCV.calculatePerspectiveTransform dstCornersPtr srcCornersPtr 900 | free srcCornersPtr 901 | free dstCornersPtr 902 | transMat <- peek transMatPtr 903 | 904 | putText "\nTransformation Matrix:" 905 | putText $ prettyShowMatrix3x3 transMat 906 | 907 | pictureMetadataEither <- loadImage inPath 908 | case pictureMetadataEither of 909 | Left error -> do 910 | free transMatPtr 911 | P.putText error 912 | Right (Bitmap bitmapData, metadatas) -> do 913 | P.putText "" -- Line break 914 | prettyPrintArray metadatas 915 | 916 | let 917 | srcWidth = P.fst bitmapData.bitmapSize 918 | srcHeight = P.snd bitmapData.bitmapSize 919 | 920 | withForeignPtr (castForeignPtr bitmapData.bitmapPointer) $ \ptr -> do 921 | resutlImg <- 922 | applyMatrix3x3 923 | srcWidth 924 | srcHeight 925 | ptr 926 | width 927 | height 928 | transMatPtr 929 | resultImgForeignPtr <- newForeignPtr_ (castPtr resutlImg) 930 | free transMatPtr 931 | 932 | let pngOutPath = replaceExtension outPath "png" 933 | 934 | case exportMode of 935 | UnmodifiedExport -> do 936 | let img = imageFromUnsafePtr width height resultImgForeignPtr 937 | savePngImage pngOutPath (ImageRGBA8 img) 938 | -- 939 | GrayscaleExport -> do 940 | grayImgPtr <- SCV.grayscaleStretch width height resutlImg 941 | grayImgForeignPtr <- newForeignPtr_ (castPtr grayImgPtr) 942 | let grayImg = imageFromUnsafePtr width height grayImgForeignPtr 943 | savePngImage pngOutPath (ImageRGBA8 grayImg) 944 | -- 945 | BlackWhiteExport -> do 946 | bwImgPtr <- SCV.bwSmart width height False resutlImg 947 | bwImgForeignPtr <- newForeignPtr_ (castPtr bwImgPtr) 948 | let bwImg = imageFromUnsafePtr width height bwImgForeignPtr 949 | savePngImage pngOutPath (ImageRGBA8 bwImg) 950 | -- 951 | BlackWhiteSmoothExport -> do 952 | bwImgPtr <- SCV.bwSmart width height True resutlImg 953 | bwImgForeignPtr <- newForeignPtr_ (castPtr bwImgPtr) 954 | let bwImg = imageFromUnsafePtr width height bwImgForeignPtr 955 | savePngImage pngOutPath (ImageRGBA8 bwImg) 956 | 957 | putText $ "\n✅ Wrote file to:" 958 | P.putStrLn pngOutPath 959 | -- 960 | Right _ -> do 961 | free transMatPtr 962 | P.putText "Unsupported image format" 963 | 964 | 965 | loadAndStart :: Config -> Maybe [FilePath] -> IO () 966 | loadAndStart config filePathsMb = do 967 | let 968 | isRegistered = True -- (config&licenseKey) `elem` licenses 969 | stateDraft = 970 | initialState 971 | { transformBackend = config.transformBackendFlag 972 | , isRegistered = isRegistered 973 | , bannerIsVisible = not isRegistered 974 | } 975 | 976 | screenSize <- getScreenSize 977 | 978 | putText "Starting the app …" 979 | 980 | case filePathsMb of 981 | Nothing -> do 982 | playIO 983 | (appStateToWindow screenSize stateDraft) 984 | black 985 | ticksPerSecond 986 | stateDraft 987 | makePicture 988 | handleEvent 989 | stepWorld 990 | Just filePaths -> do 991 | let 992 | images = 993 | filePaths <&> \filePath -> 994 | ImageToLoad{filePath = filePath} 995 | 996 | appState <- loadFileIntoState stateDraft{images = images} 997 | 998 | playIO 999 | (appStateToWindow screenSize appState) 1000 | black 1001 | ticksPerSecond 1002 | appState 1003 | makePicture 1004 | handleEvent 1005 | stepWorld 1006 | 1007 | 1008 | helpMessage :: Text 1009 | helpMessage = 1010 | T.unlines ["Usage: perspec [image…]"] 1011 | -------------------------------------------------------------------------------- /source/Rename.hs: -------------------------------------------------------------------------------- 1 | {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} 2 | 3 | {-# HLINT ignore "Use list comprehension" #-} 4 | 5 | module Rename where 6 | 7 | import Protolude as P ( 8 | Bits ((.|.)), 9 | Foldable (elem, length, null), 10 | Int, 11 | Maybe (..), 12 | Num ((*), (+), (-)), 13 | Ord ((<)), 14 | Semigroup ((<>)), 15 | Text, 16 | filter, 17 | fromMaybe, 18 | isJust, 19 | isNothing, 20 | show, 21 | sortBy, 22 | zipWith, 23 | ($), 24 | (&), 25 | (<&>), 26 | ) 27 | 28 | import Algorithms.NaturalSort (compare) 29 | import Data.Text (pack, unpack) 30 | import System.FilePath (takeExtension) 31 | 32 | import Types (RenameMode (..), SortOrder (..)) 33 | 34 | 35 | mapWithIndex :: Int -> RenameMode -> SortOrder -> (a -> Int -> b) -> [a] -> [b] 36 | mapWithIndex startNum renameMode sortOrder function elements = 37 | let 38 | realStartNum = 39 | case (renameMode, sortOrder) of 40 | (Sequential, _) -> startNum 41 | (Even, Ascending) -> ((startNum - 1) .|. 1) + 1 42 | (Even, Descending) -> ((startNum + 1) .|. 1) - 1 43 | (Odd, Ascending) -> startNum .|. 1 44 | (Odd, Descending) -> (startNum - 1) .|. 1 45 | 46 | nextNum = 47 | case (renameMode, sortOrder) of 48 | (Sequential, Ascending) -> realStartNum + 1 49 | (Sequential, Descending) -> realStartNum - 1 50 | (Even, Ascending) -> realStartNum + 2 51 | (Even, Descending) -> realStartNum - 2 52 | (Odd, Ascending) -> realStartNum + 2 53 | (Odd, Descending) -> realStartNum - 2 54 | 55 | mappings = 56 | zipWith function elements [realStartNum, nextNum ..] 57 | in 58 | mappings 59 | 60 | 61 | getRenamingBatches 62 | :: Maybe Int 63 | -> RenameMode 64 | -> SortOrder 65 | -> [Text] 66 | -> [[(Text, Text)]] 67 | getRenamingBatches startNumberMb renameMode sortOrder files = 68 | let 69 | filesSorted :: [Text] 70 | filesSorted = 71 | files 72 | <&> unpack 73 | & sortBy Algorithms.NaturalSort.compare 74 | <&> pack 75 | 76 | startNumber :: Int 77 | startNumber = 78 | case (startNumberMb, sortOrder, renameMode) of 79 | (Just val, _, _) -> val 80 | (_, Ascending, _) -> 0 81 | (_, Descending, Sequential) -> length files - 1 82 | (_, Descending, Even) -> (length files * 2) - 2 83 | (_, Descending, Odd) -> (length files * 2) - 1 84 | 85 | renamings :: [(Text, Text)] 86 | renamings = 87 | filesSorted 88 | & mapWithIndex 89 | startNumber 90 | renameMode 91 | sortOrder 92 | ( \file index -> 93 | ( file 94 | , (if index < 0 then "_todo_" else "") 95 | <> show index 96 | <> pack (takeExtension $ unpack file) 97 | ) 98 | ) 99 | 100 | renamingsWithTemp :: [(Text, Maybe Text, Text)] 101 | renamingsWithTemp = 102 | renamings 103 | <&> ( \(file, target) -> 104 | ( file 105 | , if target `elem` files 106 | then Just $ "_perspec_temp_" <> target 107 | else Nothing 108 | , target 109 | ) 110 | ) 111 | 112 | renamingsBatch1 = 113 | renamingsWithTemp 114 | <&> ( \(file, tempTargetMb, target) -> 115 | if P.isNothing tempTargetMb 116 | then (file, target) 117 | else (file, fromMaybe "" tempTargetMb) 118 | ) 119 | 120 | renamingsBatch2 = 121 | renamingsWithTemp 122 | & filter (\(_, tempTargetMb, _) -> P.isJust tempTargetMb) 123 | <&> (\(_, tempTargetMb, target) -> (fromMaybe "" tempTargetMb, target)) 124 | in 125 | [renamingsBatch1] 126 | <> ( if null renamingsBatch2 127 | then [] 128 | else [renamingsBatch2] 129 | ) 130 | -------------------------------------------------------------------------------- /source/SimpleCV.chs: -------------------------------------------------------------------------------- 1 | module SimpleCV where 2 | 3 | import Protolude ( 4 | Double, 5 | fromIntegral, 6 | identity, 7 | Int, 8 | IO, 9 | Bool, 10 | Ptr, 11 | show, 12 | realToFrac, 13 | Show, 14 | return, 15 | (*), 16 | ($), 17 | (<>), 18 | (>>=), 19 | ) 20 | 21 | import Data.Text as T 22 | import Foreign.C.Types (CUChar) 23 | import Foreign.Ptr (castPtr) 24 | import Foreign.Storable (Storable(..)) 25 | import Text.Printf (printf) 26 | 27 | #include "simplecv.h" 28 | #include "perspectivetransform.h" 29 | 30 | data Corners = Corners 31 | { tl_x :: Double 32 | , tl_y :: Double 33 | , tr_x :: Double 34 | , tr_y :: Double 35 | , br_x :: Double 36 | , br_y :: Double 37 | , bl_x :: Double 38 | , bl_y :: Double 39 | } deriving (Show) 40 | {#pointer *Corners as CornersPtr foreign -> Corners#} 41 | 42 | 43 | prettyShowCorners :: Corners -> Text 44 | prettyShowCorners Corners{..} = 45 | (show (tl_x, tl_y) <> " " <> show (tr_x, tr_y)) <> "\n" <> 46 | (show (bl_x, bl_y) <> " " <> show (br_x, br_y)) 47 | 48 | 49 | data Matrix3x3 = Matrix3x3 50 | { m00 :: Double 51 | , m01 :: Double 52 | , m02 :: Double 53 | , m10 :: Double 54 | , m11 :: Double 55 | , m12 :: Double 56 | , m20 :: Double 57 | , m21 :: Double 58 | , m22 :: Double 59 | } deriving (Show) 60 | {#pointer *Matrix3x3 as Matrix3x3Ptr foreign -> Matrix3x3#} 61 | 62 | 63 | prettyShowMatrix3x3 :: Matrix3x3 -> Text 64 | prettyShowMatrix3x3 Matrix3x3{..} = 65 | let 66 | fNum num = printf "% .5f " num 67 | in 68 | T.pack $ 69 | fNum m00 <> " " <> fNum m01 <> " " <> fNum m02 <> "\n" <> 70 | fNum m10 <> " " <> fNum m11 <> " " <> fNum m12 <> "\n" <> 71 | fNum m20 <> " " <> fNum m21 <> " " <> fNum m22 72 | 73 | 74 | instance Storable Corners where 75 | sizeOf _ = 8 * sizeOf (0.0 :: Double) 76 | alignment _ = alignment (0.0 :: Double) 77 | peek ptr = do 78 | tl_x <- peekByteOff ptr 0 79 | tl_y <- peekByteOff ptr 8 80 | tr_x <- peekByteOff ptr 16 81 | tr_y <- peekByteOff ptr 24 82 | br_x <- peekByteOff ptr 32 83 | br_y <- peekByteOff ptr 40 84 | bl_x <- peekByteOff ptr 48 85 | bl_y <- peekByteOff ptr 56 86 | return Corners{..} 87 | poke ptr Corners{..} = do 88 | pokeByteOff ptr 0 tl_x 89 | pokeByteOff ptr 8 tl_y 90 | pokeByteOff ptr 16 tr_x 91 | pokeByteOff ptr 24 tr_y 92 | pokeByteOff ptr 32 br_x 93 | pokeByteOff ptr 40 br_y 94 | pokeByteOff ptr 48 bl_x 95 | pokeByteOff ptr 56 bl_y 96 | 97 | instance Storable Matrix3x3 where 98 | sizeOf _ = 9 * sizeOf (0.0 :: Double) 99 | alignment _ = alignment (0.0 :: Double) 100 | peek ptr = do 101 | m00 <- peekByteOff ptr 0 102 | m01 <- peekByteOff ptr 8 103 | m02 <- peekByteOff ptr 16 104 | m10 <- peekByteOff ptr 24 105 | m11 <- peekByteOff ptr 32 106 | m12 <- peekByteOff ptr 40 107 | m20 <- peekByteOff ptr 48 108 | m21 <- peekByteOff ptr 56 109 | m22 <- peekByteOff ptr 64 110 | return Matrix3x3{..} 111 | poke ptr Matrix3x3{..} = do 112 | pokeByteOff ptr 0 m00 113 | pokeByteOff ptr 8 m01 114 | pokeByteOff ptr 16 m02 115 | pokeByteOff ptr 24 m10 116 | pokeByteOff ptr 32 m11 117 | pokeByteOff ptr 40 m12 118 | pokeByteOff ptr 48 m20 119 | pokeByteOff ptr 56 m21 120 | pokeByteOff ptr 64 m22 121 | 122 | {#fun grayscale 123 | { `Int' -- ^ width 124 | , `Int' -- ^ height 125 | , identity `Ptr CUChar' -- ^ Original image data 126 | } -> `Ptr CUChar' castPtr -- ^ Grayscale image data 127 | #} 128 | 129 | {#fun apply_gaussian_blur 130 | { `Int' -- ^ width 131 | , `Int' -- ^ height 132 | , `Double' -- ^ radius 133 | , identity `Ptr CUChar' -- ^ Original image data 134 | } -> `Ptr CUChar' castPtr -- ^ Blurred image data 135 | #} 136 | 137 | {#fun grayscale_stretch as ^ 138 | { `Int' -- ^ width 139 | , `Int' -- ^ height 140 | , identity `Ptr CUChar' -- ^ Original image data 141 | } -> `Ptr CUChar' castPtr -- ^ Grayscale image data 142 | #} 143 | 144 | {#fun otsu_threshold_rgba 145 | { `Int' -- ^ width 146 | , `Int' -- ^ height 147 | , `Bool' -- ^ whether to use double thresholding 148 | , identity `Ptr CUChar' -- ^ Original image data 149 | } -> `Ptr CUChar' castPtr -- ^ Thresholded image data 150 | #} 151 | 152 | {#fun calculate_perspective_transform as ^ 153 | { castPtr `Ptr Corners' -- ^ Source points 154 | , castPtr `Ptr Corners' -- ^ Destination points 155 | } -> `Ptr Matrix3x3' castPtr -- ^ Transformation matrix 156 | #} 157 | 158 | {#fun apply_matrix_3x3 as ^ 159 | { `Int' -- ^ width of the input image 160 | , `Int' -- ^ height of the input image 161 | , identity `Ptr CUChar' -- ^ Original image data 162 | , `Int' -- ^ width of the output image 163 | , `Int' -- ^ height of the output image 164 | , castPtr `Ptr Matrix3x3' -- ^ Transformation matrix 165 | } -> `Ptr CUChar' castPtr -- ^ Transformed image data 166 | #} 167 | 168 | {#fun bw_smart as ^ 169 | { `Int' -- ^ width 170 | , `Int' -- ^ height 171 | , `Bool' -- ^ whether to use double thresholding 172 | , identity `Ptr CUChar' -- ^ Original image data 173 | } -> `Ptr CUChar' castPtr -- ^ Anti-aliased black and white image data 174 | #} 175 | -------------------------------------------------------------------------------- /source/TinyFileDialogs.chs: -------------------------------------------------------------------------------- 1 | module TinyFileDialogs 2 | ( -- * The functions 3 | beep 4 | , notifyPopup 5 | , messageBox 6 | , inputBox 7 | , saveFileDialog 8 | , openFileDialog 9 | , selectFolderDialog 10 | , colorChooser 11 | -- * Message box options 12 | , IconType(..) 13 | , MessageBox 14 | , OK(..) 15 | , OKCancel(..) 16 | , YesNo(..) 17 | , YesNoCancel(..) 18 | ) where 19 | 20 | import Protolude ( 21 | Applicative (pure), 22 | Bool (..), 23 | (.), 24 | Eq ((==)), 25 | die, 26 | maxBound, 27 | minBound, 28 | return, 29 | identity, 30 | Functor (fmap), 31 | IO, 32 | Int, 33 | Maybe (Just, Nothing), 34 | Monad ((>>=)), 35 | Ord, 36 | Semigroup ((<>)), 37 | fromIntegral, 38 | show, 39 | ($), 40 | (<>), 41 | (>>), 42 | Show,Read,Enum,Bounded 43 | ) 44 | 45 | import Data.List (lookup) 46 | import Data.Char (toLower) 47 | import qualified Data.Text as T 48 | import Foreign (Ptr, Word8, nullPtr, peekArray, withArray, withArrayLen, withMany) 49 | import Foreign.C (CInt, CString, CUChar) 50 | 51 | #ifdef WINDOWS 52 | import qualified Data.ByteString as B 53 | import qualified Data.Text.Encoding as TE 54 | #else 55 | import Foreign.C (peekCString, withCString) 56 | #endif 57 | 58 | #include "tinyfiledialogs.h" 59 | 60 | {#context prefix = "tinyfd_" #} 61 | 62 | withCText :: T.Text -> (CString -> IO a) -> IO a 63 | #ifdef WINDOWS 64 | withCText = B.useAsCString . TE.encodeUtf8 65 | #else 66 | withCText = withCString . T.unpack 67 | #endif 68 | 69 | withCShowLower :: (Show a) => a -> (CString -> IO b) -> IO b 70 | withCShowLower = withCText . T.pack . fmap toLower . show 71 | 72 | withCMaybeText :: Maybe T.Text -> (CString -> IO a) -> IO a 73 | withCMaybeText mt f = case mt of 74 | Nothing -> f nullPtr 75 | Just t -> withCText t f 76 | 77 | peekMaybeText :: CString -> IO (Maybe T.Text) 78 | peekMaybeText cstr = if cstr == nullPtr 79 | then pure Nothing 80 | #ifdef WINDOWS 81 | else fmap (Just . TE.decodeUtf8) $ B.packCString cstr 82 | #else 83 | else fmap (Just . T.pack) $ peekCString cstr 84 | #endif 85 | 86 | peekMaybeTextMultiple :: CString -> IO (Maybe [T.Text]) 87 | peekMaybeTextMultiple = fmap (fmap $ T.splitOn (T.singleton '|')) . peekMaybeText 88 | 89 | withCTexts :: [T.Text] -> ((CInt, Ptr CString) -> IO a) -> IO a 90 | withCTexts ts f = withMany withCText ts $ \ptrs -> 91 | withArrayLen ptrs $ \len ptr -> f (fromIntegral len, ptr) 92 | 93 | class (Enum a, Bounded a) => MessageBox a where 94 | messageBoxType :: a -> T.Text 95 | messageBoxValue :: a -> Int 96 | 97 | data IconType = Info | Warning | Error | Question 98 | deriving (Eq, Ord, Show, Read, Enum, Bounded) 99 | 100 | data OK = OK 101 | deriving (Eq, Ord, Show, Read, Enum, Bounded) 102 | 103 | instance MessageBox OK where 104 | messageBoxType _ = T.pack "ok" 105 | messageBoxValue OK = 1 106 | 107 | data OKCancel = OC_OK | OC_Cancel 108 | deriving (Eq, Ord, Show, Read, Enum, Bounded) 109 | 110 | instance MessageBox OKCancel where 111 | messageBoxType _ = T.pack "okcancel" 112 | messageBoxValue OC_Cancel = 0 113 | messageBoxValue OC_OK = 1 114 | 115 | data YesNo = YN_Yes | YN_No 116 | deriving (Eq, Ord, Show, Read, Enum, Bounded) 117 | 118 | instance MessageBox YesNo where 119 | messageBoxType _ = T.pack "yesno" 120 | messageBoxValue YN_No = 0 121 | messageBoxValue YN_Yes = 1 122 | 123 | data YesNoCancel = YNC_Yes | YNC_No | YNC_Cancel 124 | deriving (Eq, Ord, Show, Read, Enum, Bounded) 125 | 126 | instance MessageBox YesNoCancel where 127 | messageBoxType _ = T.pack "yesnocancel" 128 | messageBoxValue YNC_Cancel = 0 129 | messageBoxValue YNC_Yes = 1 130 | messageBoxValue YNC_No = 2 131 | 132 | {#fun messageBox as c_messageBox 133 | { withCText* `T.Text' -- ^ title 134 | , withCText* `T.Text' -- ^ message, may contain @\\n@ and @\\t@ 135 | , withCText* `T.Text' -- ^ @"ok" "okcancel" "yesno" "yesnocancel"@ 136 | , withCShowLower* `IconType' -- ^ 'Info', 'Warning', 'Error', 'Question' 137 | , `Int' -- ^ default button: 0 for cancel/no, 1 for ok/yes, 2 for no in yesnocancel 138 | } -> `Int' -- ^ 0 for cancel/no, 1 for ok/yes, 2 for no in yesnocancel 139 | #} 140 | 141 | {#fun notifyPopup 142 | { withCText* `T.Text' -- ^ title 143 | , withCText* `T.Text' -- ^ message, may contain @\\n@ and @\\t@ 144 | , withCShowLower* `IconType' -- ^ 'Info', 'Warning', 'Error' 145 | } -> `()' 146 | #} 147 | 148 | {#fun beep {} -> `()' #} 149 | 150 | messageBox 151 | :: (MessageBox a) 152 | => T.Text -- ^ title 153 | -> T.Text -- ^ message, may contain @\\n@ and @\\t@ 154 | -> IconType -- ^ 'Info', 'Warning', 'Error', 'Question' 155 | -> a -- ^ default button 156 | -> IO a 157 | messageBox ttl msg icon dflt = do 158 | n <- c_messageBox ttl msg (messageBoxType dflt) icon (messageBoxValue dflt) 159 | case lookup n [ (messageBoxValue x, x) | x <- [minBound .. maxBound] ] of 160 | Just x -> pure x 161 | Nothing -> die $ "TinyFileDialogs.messageBox: " 162 | <> "internal error; unrecognized return value " <> show n 163 | 164 | {#fun inputBox 165 | { withCText* `T.Text' -- ^ title 166 | , withCText* `T.Text' -- ^ message, may NOT contain @\\n@ and @\\t@ on windows 167 | , withCMaybeText* `Maybe T.Text' -- ^ default input, if 'Nothing' it's a passwordBox 168 | } -> `Maybe T.Text' peekMaybeText* -- ^ returns 'Nothing' on cancel 169 | #} 170 | 171 | {#fun saveFileDialog 172 | { withCText* `T.Text' -- ^ title 173 | , withCText* `T.Text' -- ^ default path and file 174 | , withCTexts* `[T.Text]'& -- ^ filter patterns, @["*.jpg","*.png"]@ 175 | , withCText* `T.Text' -- ^ single filter description, @"text files"@ 176 | } -> `Maybe T.Text' peekMaybeText* -- ^ returns 'Nothing' on cancel 177 | #} 178 | 179 | {#fun openFileDialog 180 | { withCText* `T.Text' -- ^ title 181 | , withCText* `T.Text' -- ^ default path and file 182 | , withCTexts* `[T.Text]'& -- ^ filter patterns, @["*.jpg","*.png"]@ 183 | , withCText* `T.Text' -- ^ single filter description, @"text files"@ 184 | , `Bool' -- ^ allow multiple selects 185 | } -> `Maybe [T.Text]' peekMaybeTextMultiple* -- ^ returns 'Nothing' on cancel 186 | #} 187 | 188 | {#fun selectFolderDialog 189 | { withCText* `T.Text' -- ^ title 190 | , withCText* `T.Text' -- ^ default path 191 | } -> `Maybe T.Text' peekMaybeText* -- ^ returns 'Nothing' on cancel 192 | #} 193 | 194 | {#fun colorChooser as c_colorChooser 195 | { withCText* `T.Text' 196 | , withCMaybeText* `Maybe T.Text' 197 | , identity `Ptr CUChar' 198 | , identity `Ptr CUChar' 199 | } -> `Maybe T.Text' peekMaybeText* -- ^ returns 'Nothing' on cancel 200 | #} 201 | 202 | withColor :: (Word8, Word8, Word8) -> (Ptr CUChar -> IO a) -> IO a 203 | withColor (r, g, b) = withArray $ fmap fromIntegral [r, g, b] 204 | 205 | colorChooser 206 | :: T.Text -- ^ title 207 | -> (Word8, Word8, Word8) -- ^ default RGB color 208 | -> IO (Maybe (Word8, Word8, Word8)) -- ^ returns 'Nothing' on cancel 209 | colorChooser title color = withColor color $ \ptr -> do 210 | res <- c_colorChooser title Nothing ptr ptr 211 | case res of 212 | Nothing -> pure Nothing 213 | Just _ -> fmap 214 | ((\case 215 | [r, g, b] -> Just (r, g, b) 216 | _ -> Nothing 217 | ) . fmap fromIntegral) 218 | (peekArray 3 ptr) 219 | -------------------------------------------------------------------------------- /source/Types.hs: -------------------------------------------------------------------------------- 1 | module Types where 2 | 3 | import Protolude as P ( 4 | Applicative (pure), 5 | Bool (False), 6 | Eq, 7 | FilePath, 8 | Float, 9 | Generic, 10 | Int, 11 | Maybe (Nothing), 12 | Monad (return), 13 | Show, 14 | Text, 15 | fst, 16 | snd, 17 | ($), 18 | ) 19 | 20 | import Brillo (Picture, Point) 21 | import Data.Aeson ( 22 | FromJSON (parseJSON), 23 | withObject, 24 | withText, 25 | (.!=), 26 | (.:), 27 | (.:?), 28 | ) 29 | 30 | 31 | data Config = Config 32 | { licenseKey :: Text 33 | , transformBackendFlag :: TransformBackend 34 | } 35 | deriving (Generic, Show) 36 | 37 | 38 | -- | Necessary to make fields optional without using a Maybe type 39 | instance FromJSON Config where 40 | parseJSON = withObject "config" $ \o -> do 41 | licenseKey <- o .:? "licenseKey" .!= "" 42 | transformBackendFlag <- o .:? "transformBackend" .!= SimpleCVBackend 43 | pure $ Config{..} 44 | 45 | 46 | type Corner = Point 47 | 48 | 49 | type CornersTup = (Corner, Corner, Corner, Corner) 50 | 51 | 52 | -- | Projection map from corner to corner 53 | type ProjMap = 54 | ( (Corner, Corner) 55 | , (Corner, Corner) 56 | , (Corner, Corner) 57 | , (Corner, Corner) 58 | ) 59 | 60 | 61 | data Coordinate = Coordinate 62 | { x :: Float 63 | , y :: Float 64 | } 65 | deriving (Show, Eq) 66 | 67 | 68 | instance FromJSON Coordinate where 69 | parseJSON = withObject "Coordinate" $ \o -> do 70 | x <- o .: "x" 71 | y <- o .: "y" 72 | pure $ Coordinate{..} 73 | 74 | 75 | coordToCornersTup :: [Coordinate] -> CornersTup 76 | coordToCornersTup coordinates = 77 | case coordinates of 78 | [c1, c2, c3, c4] -> 79 | ( (c1.x, c1.y) 80 | , (c2.x, c2.y) 81 | , (c3.x, c3.y) 82 | , (c4.x, c4.y) 83 | ) 84 | _ -> ((0, 0), (0, 0), (0, 0), (0, 0)) 85 | 86 | 87 | cornersTupToCoord :: CornersTup -> [Coordinate] 88 | cornersTupToCoord (c1, c2, c3, c4) = 89 | [ Coordinate (P.fst c1) (P.snd c1) 90 | , Coordinate (P.fst c2) (P.snd c2) 91 | , Coordinate (P.fst c3) (P.snd c3) 92 | , Coordinate (P.fst c4) (P.snd c4) 93 | ] 94 | 95 | 96 | {-| Not used at the moment 97 | rotateProjMap :: Float -> ProjMap -> ProjMap 98 | rotateProjMap rotation pMap@((f1,t1), (f2,t2), (f3,t3), (f4,t4)) = 99 | case rotation of 100 | -90 -> ((f1,t4), (f2,t1), (f3,t2), (f4,t3)) 101 | 90 -> ((f1,t2), (f1,t3), (f1,t4), (f1,t1)) 102 | 180 -> ((f1,t3), (f1,t4), (f1,t1), (f1,t2)) 103 | _ -> pMap 104 | -} 105 | data ConversionMode 106 | = CallConversion 107 | | SpawnConversion 108 | 109 | 110 | data TransformBackend 111 | = ImageMagickBackend 112 | | HipBackend 113 | | SimpleCVBackend 114 | deriving (Show, Eq) 115 | 116 | 117 | instance FromJSON TransformBackend where 118 | parseJSON = withText "TransformBackend" $ \case 119 | "ImageMagick" -> return ImageMagickBackend 120 | "Hip" -> return HipBackend 121 | "SimpleCV" -> return SimpleCVBackend 122 | _ -> return SimpleCVBackend 123 | 124 | 125 | data RenameMode 126 | = Sequential 127 | | Even 128 | | Odd 129 | deriving (Show) 130 | 131 | 132 | data SortOrder 133 | = Ascending 134 | | Descending 135 | 136 | 137 | data ExportMode 138 | = UnmodifiedExport 139 | | GrayscaleExport 140 | | BlackWhiteExport 141 | | BlackWhiteSmoothExport 142 | 143 | 144 | data UiComponent 145 | = Button 146 | { text :: Text 147 | , width :: Int 148 | , height :: Int 149 | , bgColor :: Int 150 | } 151 | | Select 152 | deriving (Show) 153 | 154 | 155 | data View = HomeView | ImageView | BannerView 156 | deriving (Show) 157 | 158 | 159 | data ImageData 160 | = ImageToLoad {filePath :: FilePath} 161 | | ImageData 162 | { inputPath :: FilePath 163 | , outputPath :: FilePath 164 | , width :: Int 165 | , height :: Int 166 | , widthTarget :: Int 167 | , heightTarget :: Int 168 | , content :: Picture 169 | , rotation :: Float 170 | } 171 | deriving (Show) 172 | 173 | 174 | -- | State of app 175 | data AppState = AppState 176 | { currentView :: View 177 | , tickCounter :: Int 178 | , corners :: [Corner] 179 | -- ^ Reversed to order of addition 180 | -- ^ (0, 0) is center of coordinate system 181 | , cornerDragged :: Maybe Corner 182 | -- ^ Currently dragged corner 183 | , images :: [ImageData] 184 | , appWidth :: Int 185 | , appHeight :: Int 186 | , scaleFactor :: Float -- TODO: Should be smaller than 1 187 | , transformBackend :: TransformBackend 188 | , isRegistered :: Bool 189 | , bannerIsVisible :: Bool 190 | , sidebarWidth :: Int 191 | , sidebarColor :: Int 192 | , uiComponents :: [UiComponent] 193 | } 194 | deriving (Show) 195 | 196 | 197 | appInitialWidth, appInitialHeight, sidebarInitialWidth :: Int 198 | appInitialWidth = 1280 199 | appInitialHeight = 960 200 | sidebarInitialWidth = 180 201 | 202 | 203 | initialState :: AppState 204 | initialState = 205 | AppState 206 | { currentView = HomeView 207 | , tickCounter = 0 208 | , corners = [] 209 | , cornerDragged = Nothing 210 | , images = [] 211 | , appWidth = appInitialWidth 212 | , appHeight = appInitialHeight 213 | , scaleFactor = 1 214 | , transformBackend = SimpleCVBackend 215 | , isRegistered = False 216 | , bannerIsVisible = False 217 | , sidebarWidth = sidebarInitialWidth 218 | , sidebarColor = 0 219 | , uiComponents = 220 | [ Button 221 | { text = "Save" 222 | , width = 160 223 | , height = 30 224 | , bgColor = 0 225 | } 226 | , Button 227 | { text = "Save Gray" 228 | , width = 160 229 | , height = 30 230 | , bgColor = 0 231 | } 232 | , Button 233 | { text = "Save BW" 234 | , width = 160 235 | , height = 30 236 | , bgColor = 0 237 | } 238 | , Button 239 | { text = "Save BW Smooth" 240 | , width = 160 241 | , height = 30 242 | , bgColor = 0 243 | } 244 | ] 245 | } 246 | -------------------------------------------------------------------------------- /source/Utils.hs: -------------------------------------------------------------------------------- 1 | {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} 2 | 3 | {-# HLINT ignore "Use maybe" #-} 4 | module Utils where 5 | 6 | import Protolude ( 7 | Bool (..), 8 | ByteString, 9 | Either (..), 10 | FilePath, 11 | Float, 12 | IO, 13 | Int, 14 | Maybe (Just, Nothing), 15 | Monad ((>>=)), 16 | Monoid (mempty), 17 | Text, 18 | const, 19 | either, 20 | fmap, 21 | fromIntegral, 22 | fromMaybe, 23 | min, 24 | pure, 25 | putText, 26 | round, 27 | show, 28 | swap, 29 | ($), 30 | (&), 31 | (&&), 32 | (*), 33 | (+), 34 | (-), 35 | (/), 36 | (<&>), 37 | (<=), 38 | (<>), 39 | (==), 40 | (>=), 41 | ) 42 | import Protolude qualified as P 43 | 44 | import Brillo ( 45 | BitmapData (bitmapSize), 46 | Picture (Bitmap, BitmapSection, Rotate), 47 | Point, 48 | Rectangle (Rectangle, rectPos, rectSize), 49 | ) 50 | import Brillo.Juicy (fromDynamicImage, loadJuicyWithMetadata) 51 | import Codec.Picture (decodePng) 52 | import Codec.Picture.Metadata (Keys (Exif), Metadatas, lookup) 53 | import Codec.Picture.Metadata.Exif (ExifData (ExifShort), ExifTag (..)) 54 | import Control.Arrow ((>>>)) 55 | import Data.Aeson qualified as Aeson 56 | import Data.Bifunctor (bimap) 57 | import Data.ByteString.Lazy qualified as BL 58 | import Data.FileEmbed (embedFile) 59 | import Data.Text qualified as T 60 | import Data.Text.Encoding qualified as TSE 61 | import System.Directory (getCurrentDirectory) 62 | import System.FilePath (replaceBaseName, takeBaseName, takeExtension, ()) 63 | import System.Info (os) 64 | import System.Process (readProcessWithExitCode) 65 | 66 | import Brillo.Data.Picture (Picture (Scale)) 67 | import Types (AppState (..), Coordinate (..), Corner, ImageData (..), View (..)) 68 | 69 | 70 | -- | Embed the words sprite image with a scale factor of 2 71 | wordsSprite :: (ByteString, Float) 72 | wordsSprite = ($(embedFile "images/words@2x.png"), 2) 73 | 74 | 75 | wordsPic :: Picture 76 | wordsPic = 77 | fromMaybe mempty $ 78 | either (const Nothing) Just (decodePng $ P.fst wordsSprite) 79 | >>= fromDynamicImage 80 | 81 | 82 | {-| `rectPos` is the position of the content 83 | `rectSize` ist the size of the content 84 | -} 85 | getWordSprite :: Text -> Picture 86 | getWordSprite spriteText = do 87 | let 88 | scaleFactor = 1 / P.snd wordsSprite 89 | scaleVal = fromIntegral >>> (* P.snd wordsSprite) >>> round 90 | scaleRect rect = 91 | Rectangle 92 | { rectPos = bimap scaleVal scaleVal rect.rectPos 93 | , rectSize = bimap scaleVal scaleVal rect.rectSize 94 | } 95 | case wordsPic of 96 | Bitmap bitmapData -> 97 | Scale scaleFactor scaleFactor $ case spriteText of 98 | "Save" -> 99 | BitmapSection 100 | (scaleRect Rectangle{rectPos = (0, 40), rectSize = (40, 20)}) 101 | bitmapData 102 | "Save BW" -> 103 | BitmapSection 104 | (scaleRect Rectangle{rectPos = (0, 60), rectSize = (74, 20)}) 105 | bitmapData 106 | "Save Gray" -> 107 | BitmapSection 108 | (scaleRect Rectangle{rectPos = (0, 80), rectSize = (84, 20)}) 109 | bitmapData 110 | "Select Files" -> 111 | BitmapSection 112 | (scaleRect Rectangle{rectPos = (0, 140), rectSize = (92, 20)}) 113 | bitmapData 114 | "Save BW Smooth" -> 115 | BitmapSection 116 | (scaleRect Rectangle{rectPos = (0, 160), rectSize = (140, 20)}) 117 | bitmapData 118 | _ -> mempty 119 | _ -> mempty 120 | 121 | 122 | isInRect :: Point -> (Float, Float, Float, Float) -> Bool 123 | isInRect (x, y) (x1, y1, x2, y2) = 124 | x >= x1 && x <= x2 && y >= y1 && y <= y2 125 | 126 | 127 | getOutPath :: FilePath -> FilePath 128 | getOutPath filePath = do 129 | let outName = takeBaseName filePath <> "-fixed" 130 | replaceBaseName filePath outName 131 | 132 | 133 | calcInitWindowPos :: (Int, Int) -> (Int, Int) -> (Int, Int) 134 | calcInitWindowPos (screenWidth, screenHeight) (appWidth, appHeight) = do 135 | let 136 | initialX = 137 | ((fromIntegral screenWidth :: Float) / 2) 138 | - (fromIntegral appWidth / 2) 139 | initialY = 140 | ((fromIntegral screenHeight :: Float) / 2) 141 | - (fromIntegral appHeight / 2) 142 | 143 | (round initialX, round initialY) 144 | 145 | 146 | -- | Transform from origin in center to origin in top left 147 | transToOrigTopLeft :: Int -> Int -> [Point] -> [Point] 148 | transToOrigTopLeft width height = 149 | fmap 150 | ( \(x, y) -> 151 | ( x + (fromIntegral width / 2.0) 152 | , -(y - (fromIntegral height / 2.0)) 153 | ) 154 | ) 155 | 156 | 157 | -- | Transform from origin in top left to origin in center 158 | transToOrigAtCenter :: Int -> Int -> [Point] -> [Point] 159 | transToOrigAtCenter width height = 160 | fmap 161 | ( \(x, y) -> 162 | ( -((fromIntegral width / 2.0) - x) 163 | , (fromIntegral height / 2.0) - y 164 | ) 165 | ) 166 | 167 | 168 | scalePoints :: Float -> [Point] -> [Point] 169 | scalePoints scaleFac = fmap $ 170 | \(x, y) -> (x / scaleFac, y / scaleFac) 171 | 172 | 173 | getCorners :: AppState -> [Point] 174 | getCorners appState = 175 | case appState.images of 176 | [] -> [] 177 | image : _otherImages -> do 178 | scalePoints appState.scaleFactor $ 179 | transToOrigTopLeft 180 | image.widthTarget 181 | image.heightTarget 182 | (P.reverse $ corners appState) 183 | 184 | 185 | {-| Calculate the target image size, the scale factor, 186 | and the corner positions for current image 187 | -} 188 | calculateSizes :: AppState -> AppState 189 | calculateSizes appState = 190 | case appState.images of 191 | [] -> appState 192 | image : otherImages -> do 193 | let 194 | imgViewWidth = appState.appWidth - appState.sidebarWidth 195 | imgViewHeight = appState.appHeight 196 | 197 | imgWidthFrac = fromIntegral image.width 198 | imgHeightFrac = fromIntegral image.height 199 | 200 | scaleFactorX = fromIntegral imgViewWidth / imgWidthFrac 201 | scaleFactorY = fromIntegral imgViewHeight / imgHeightFrac 202 | 203 | scaleFactor = min scaleFactorX scaleFactorY 204 | imgWidthTrgt = round $ scaleFactor * imgWidthFrac 205 | imgHeightTrgt = round $ scaleFactor * imgHeightFrac 206 | 207 | appState 208 | { images = 209 | image 210 | { widthTarget = imgWidthTrgt 211 | , heightTarget = imgHeightTrgt 212 | } 213 | : otherImages 214 | , scaleFactor 215 | , corners = 216 | transToOrigTopLeft (-imgWidthTrgt) imgHeightTrgt $ 217 | scalePoints (1 / scaleFactor) (getCorners appState) 218 | } 219 | 220 | 221 | imgOrientToRot :: ExifData -> Float 222 | imgOrientToRot = \case 223 | ExifShort 6 -> -90 224 | ExifShort 1 -> 0 225 | ExifShort 8 -> 90 226 | ExifShort 3 -> 180 227 | -- TODO: Also apply mirroring to image 228 | ExifShort 5 -> -90 229 | ExifShort 2 -> 0 230 | ExifShort 7 -> 90 231 | ExifShort 4 -> 180 232 | _ -> 0 233 | 234 | 235 | loadImage :: FilePath -> IO (Either Text (Picture, Metadatas)) 236 | loadImage filePath = do 237 | picMetaMaybe <- loadJuicyWithMetadata filePath 238 | 239 | let 240 | allowedExtensions = 241 | [ ".jpeg" 242 | , ".jpg" 243 | , ".png" 244 | , ".bmp" 245 | , ".gif" 246 | , ".hdr" 247 | ] 248 | fileExtension = takeExtension filePath 249 | 250 | case picMetaMaybe of 251 | Nothing -> do 252 | if P.elem fileExtension allowedExtensions 253 | then pure $ Left "Error: Image couldn't be loaded" 254 | else 255 | pure $ 256 | Left $ 257 | "Error: File extension \"" 258 | <> T.pack fileExtension 259 | <> "\" is not supported" 260 | Just (picture, metadata) -> 261 | pure $ Right (picture, metadata) 262 | 263 | 264 | -- | Get initial corner positions by shelling out to a Python script 265 | getInitialCorners :: AppState -> FilePath -> IO [Corner] 266 | getInitialCorners appState inPath = do 267 | case appState.images of 268 | [] -> pure [] 269 | image : _otherImages -> do 270 | currentDir <- getCurrentDirectory 271 | 272 | let 273 | wdthFrac = fromIntegral image.width 274 | hgtFrac = fromIntegral image.height 275 | 276 | pyScriptPathMac = currentDir "scripts/perspectra/perspectra" 277 | pyScriptPathWindows = currentDir "TODO: Windows EXE path" 278 | 279 | -- Run the Python script 280 | let 281 | pyScriptPath = 282 | if os == "mingw32" 283 | then pyScriptPathWindows 284 | else pyScriptPathMac 285 | 286 | (exitCode, stdout, stderr) <- 287 | readProcessWithExitCode pyScriptPath ["corners", inPath] "" 288 | 289 | if exitCode == P.ExitSuccess 290 | then do 291 | let 292 | -- Parse JSON output in stdout with Aeson in the form of: 293 | -- [{x: 0, y: 0}, {x: 0, y: 0}, {x: 0, y: 0}, {x: 0, y: 0}] 294 | corners :: Maybe [Coordinate] = 295 | Aeson.decode $ BL.fromStrict (TSE.encodeUtf8 $ T.pack stdout) 296 | 297 | pure $ 298 | corners 299 | & fromMaybe [] 300 | & P.map (\coord -> (coord.x, coord.y)) 301 | & transToOrigAtCenter image.width image.height 302 | & scalePoints (1 / appState.scaleFactor) 303 | & P.reverse 304 | else do 305 | P.putErrLn stderr 306 | 307 | let 308 | -- Initial distance of the corners from the image border 309 | distance = 0.1 310 | 311 | pure $ 312 | transToOrigTopLeft (-image.widthTarget) image.heightTarget $ 313 | scalePoints (1 / appState.scaleFactor) $ 314 | P.reverse 315 | [ (wdthFrac * distance, hgtFrac * distance) 316 | , (wdthFrac * (1 - distance), hgtFrac * distance) 317 | , (wdthFrac * (1 - distance), hgtFrac * (1 - distance)) 318 | , (wdthFrac * distance, hgtFrac * (1 - distance)) 319 | ] 320 | 321 | 322 | loadFileIntoState :: AppState -> IO AppState 323 | loadFileIntoState appState = do 324 | case appState.images of 325 | [] -> pure appState 326 | image : otherImages -> do 327 | case image of 328 | ImageData{} -> do 329 | putText "Error: Image was already loaded" 330 | pure appState 331 | ImageToLoad filePath -> do 332 | pictureMetadataEither <- loadImage filePath 333 | 334 | case pictureMetadataEither of 335 | Left error -> do 336 | putText error 337 | pure appState 338 | Right (picture@(Bitmap bitmapData), metadata) -> do 339 | let 340 | rotation = 341 | lookup (Exif TagOrientation) metadata 342 | <&> imgOrientToRot 343 | & fromMaybe 0 344 | sizeTuple = bitmapSize bitmapData 345 | (imgWdth, imgHgt) = case rotation of 346 | 90 -> swap sizeTuple 347 | -90 -> swap sizeTuple 348 | _ -> sizeTuple 349 | 350 | putText $ 351 | "Loaded file " 352 | <> T.pack filePath 353 | <> " " 354 | <> show (imgWdth, imgHgt) 355 | <> " " 356 | <> "with a rotation of " 357 | <> show rotation 358 | <> " degrees." 359 | 360 | let 361 | stateWithSizes = 362 | calculateSizes $ 363 | appState 364 | { currentView = ImageView 365 | , images = 366 | ImageData 367 | { inputPath = filePath 368 | , outputPath = getOutPath filePath 369 | , width = imgWdth 370 | , height = imgHgt 371 | , widthTarget = 0 -- calculateSizes will set it 372 | , heightTarget = 0 -- calculateSizes will set it 373 | , rotation = rotation 374 | , content = Rotate (-rotation) picture 375 | } 376 | : otherImages 377 | } 378 | 379 | corners <- getInitialCorners stateWithSizes filePath 380 | let stateWithCorners = stateWithSizes{corners = corners} 381 | 382 | pure stateWithCorners 383 | Right _ -> do 384 | putText $ 385 | "Error: Loaded file is not a Bitmap image. " 386 | <> "This error should not be possible." 387 | pure appState 388 | 389 | 390 | prettyPrintArray :: (P.Show a) => a -> IO () 391 | prettyPrintArray = 392 | show 393 | >>> T.replace "[" "\n[ " 394 | >>> T.replace "]" "\n] " 395 | >>> T.replace "," "\n, " 396 | >>> P.putText 397 | 398 | 399 | prettyPrintRecord :: (P.Show a) => a -> IO () 400 | prettyPrintRecord = 401 | show 402 | >>> T.replace "{" "\n{ " 403 | >>> T.replace "}" "\n} " 404 | >>> T.replace "," "\n," 405 | >>> P.putText 406 | -------------------------------------------------------------------------------- /stack.yaml: -------------------------------------------------------------------------------- 1 | # Only update to lts-23 once HLS supports GHC 9.8.4 2 | # https://haskell-language-server.readthedocs.io/en/latest/support/ghc-version-support.html 3 | resolver: lts-23.18 4 | 5 | packages: 6 | - . 7 | 8 | extra-deps: 9 | - docopt-0.7.0.8 10 | 11 | - github: lehins/hip 12 | commit: ddfc77feb21722babccd3b3aa73c4c3d41268f54 13 | subdirs: 14 | - hip 15 | 16 | - github: lehins/Color 17 | commit: ec833edd8f9b15543855c00826c4ede470773f83 18 | subdirs: 19 | - Color 20 | 21 | - github: ad-si/Brillo 22 | commit: ab2dc5244194d184740c5d0de909669141dd1313 23 | subdirs: 24 | - brillo 25 | - brillo-algorithms 26 | - brillo-juicy 27 | - brillo-rendering 28 | 29 | allow-newer: true 30 | -------------------------------------------------------------------------------- /stack.yaml.lock: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by Stack. 2 | # You should not edit this file by hand. 3 | # For more information, please see the documentation at: 4 | # https://docs.haskellstack.org/en/stable/topics/lock_files 5 | 6 | packages: 7 | - completed: 8 | hackage: docopt-0.7.0.8@sha256:d96a4e95322abdab048c7c886617b8ec302b950475c9a8f0827070b0a27fe88e,3485 9 | pantry-tree: 10 | sha256: 42c11c3bba6ed673728b73230b9b71c18e778d8fa192e7a176fb6a3570890145 11 | size: 1179 12 | original: 13 | hackage: docopt-0.7.0.8 14 | - completed: 15 | name: hip 16 | pantry-tree: 17 | sha256: 53735040ab8bfe0221f9ee71e3f5cee0b9b11a73926c400a906e6a9cbc1b3864 18 | size: 2896 19 | sha256: 28ed9b657c5bd2eed0ea08cc2d06aea24a89a949e7b5f1babcdf6e2c69d0c0be 20 | size: 13585656 21 | subdir: hip 22 | url: https://github.com/lehins/hip/archive/ddfc77feb21722babccd3b3aa73c4c3d41268f54.tar.gz 23 | version: 2.0.0.0 24 | original: 25 | subdir: hip 26 | url: https://github.com/lehins/hip/archive/ddfc77feb21722babccd3b3aa73c4c3d41268f54.tar.gz 27 | - completed: 28 | name: Color 29 | pantry-tree: 30 | sha256: 4eb4766d2fa2eccf58b735a034e9a73d471a89448248ffb9ccf8eb3738a18523 31 | size: 17484 32 | sha256: eb956cbe9fd5d0d7d9eea7d13bdddb2a26734cba3537904b7585a0fb7da4cbc5 33 | size: 125805 34 | subdir: Color 35 | url: https://github.com/lehins/Color/archive/ec833edd8f9b15543855c00826c4ede470773f83.tar.gz 36 | version: 0.4.0 37 | original: 38 | subdir: Color 39 | url: https://github.com/lehins/Color/archive/ec833edd8f9b15543855c00826c4ede470773f83.tar.gz 40 | - completed: 41 | name: brillo 42 | pantry-tree: 43 | sha256: 3aa5c12001f31dd707fbb3919a89d48f753e47b4bc1d229e953b2ec61d3c3457 44 | size: 3884 45 | sha256: c707b652c139bbe490ed04067bf0d0218450ad9e4d8cb9ab2a9ab92f0ea4f1ac 46 | size: 8245102 47 | subdir: brillo 48 | url: https://github.com/ad-si/Brillo/archive/ab2dc5244194d184740c5d0de909669141dd1313.tar.gz 49 | version: 1.13.3 50 | original: 51 | subdir: brillo 52 | url: https://github.com/ad-si/Brillo/archive/ab2dc5244194d184740c5d0de909669141dd1313.tar.gz 53 | - completed: 54 | name: brillo-algorithms 55 | pantry-tree: 56 | sha256: 5a828eb5064cce7bea8124e1c087566069d0b4e054976633f15ff33ddeb7914d 57 | size: 368 58 | sha256: c707b652c139bbe490ed04067bf0d0218450ad9e4d8cb9ab2a9ab92f0ea4f1ac 59 | size: 8245102 60 | subdir: brillo-algorithms 61 | url: https://github.com/ad-si/Brillo/archive/ab2dc5244194d184740c5d0de909669141dd1313.tar.gz 62 | version: 1.13.3 63 | original: 64 | subdir: brillo-algorithms 65 | url: https://github.com/ad-si/Brillo/archive/ab2dc5244194d184740c5d0de909669141dd1313.tar.gz 66 | - completed: 67 | name: brillo-juicy 68 | pantry-tree: 69 | sha256: 7e84991345f1cebcc03b9cead619e5c0d5bc553f2e3e67ffb3b54527ae2c05a0 70 | size: 315 71 | sha256: c707b652c139bbe490ed04067bf0d0218450ad9e4d8cb9ab2a9ab92f0ea4f1ac 72 | size: 8245102 73 | subdir: brillo-juicy 74 | url: https://github.com/ad-si/Brillo/archive/ab2dc5244194d184740c5d0de909669141dd1313.tar.gz 75 | version: 0.2.4 76 | original: 77 | subdir: brillo-juicy 78 | url: https://github.com/ad-si/Brillo/archive/ab2dc5244194d184740c5d0de909669141dd1313.tar.gz 79 | - completed: 80 | name: brillo-rendering 81 | pantry-tree: 82 | sha256: 2ac3f6a1b98d827570d1a254ca0c402a51c0f854a01e5963a053c5072033e34e 83 | size: 907 84 | sha256: c707b652c139bbe490ed04067bf0d0218450ad9e4d8cb9ab2a9ab92f0ea4f1ac 85 | size: 8245102 86 | subdir: brillo-rendering 87 | url: https://github.com/ad-si/Brillo/archive/ab2dc5244194d184740c5d0de909669141dd1313.tar.gz 88 | version: 1.13.3 89 | original: 90 | subdir: brillo-rendering 91 | url: https://github.com/ad-si/Brillo/archive/ab2dc5244194d184740c5d0de909669141dd1313.tar.gz 92 | snapshots: 93 | - completed: 94 | sha256: d133abe75e408a407cce3f032c96ac1bbadf474a93b5156ebf4135b53382d56b 95 | size: 683827 96 | url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/23/18.yaml 97 | original: lts-23.18 98 | -------------------------------------------------------------------------------- /test/Spec.hs: -------------------------------------------------------------------------------- 1 | import Test.Hspec ( 2 | describe, 3 | expectationFailure, 4 | hspec, 5 | it, 6 | pendingWith, 7 | shouldBe, 8 | shouldContain, 9 | shouldSatisfy, 10 | ) 11 | 12 | import Protolude ( 13 | Bool (False, True), 14 | Either (Left, Right), 15 | IO, 16 | Maybe (Just, Nothing), 17 | pure, 18 | show, 19 | ($), 20 | (&&), 21 | (==), 22 | ) 23 | import Protolude qualified as P 24 | 25 | import Foreign (castForeignPtr, newForeignPtr_, withForeignPtr) 26 | import Foreign.Ptr (castPtr) 27 | 28 | import Brillo ( 29 | BitmapFormat (BitmapFormat), 30 | Picture (Bitmap), 31 | PixelFormat (PxRGBA), 32 | RowOrder (TopToBottom), 33 | ) 34 | import Brillo.Rendering (BitmapData (..), bitmapOfForeignPtr) 35 | 36 | import Rename (getRenamingBatches) 37 | import SimpleCV (otsu_threshold_rgba) 38 | import Types ( 39 | RenameMode (Even, Odd, Sequential), 40 | SortOrder (Ascending, Descending), 41 | ) 42 | import Utils (loadImage) 43 | 44 | 45 | main :: IO () 46 | main = hspec $ do 47 | describe "Perspec" $ do 48 | describe "Lib" $ do 49 | it "Applies EXIF rotation to JPEGs" $ do 50 | pictureMetadataEither <- loadImage "images/doc_rotated.jpg" 51 | 52 | case pictureMetadataEither of 53 | Right (Bitmap bitmapData, metadata) -> do 54 | bitmapSize bitmapData `shouldBe` (880, 1500) 55 | 56 | -- Does not provide an Eq instance => Misuse show 57 | let metadataText = show metadata 58 | 59 | metadataText `shouldContain` "TagOrientation :=> ExifShort 6" 60 | metadataText `shouldContain` "(TagUnknown 40962) :=> ExifLong 880" 61 | metadataText `shouldContain` "(TagUnknown 40963) :=> ExifLong 1500" 62 | _ -> expectationFailure "File should have been loaded" 63 | 64 | it "Applies EXIF rotation to PNGs" $ do 65 | pictureMetadataEither <- loadImage "images/rotated.png" 66 | 67 | case pictureMetadataEither of 68 | Right (Bitmap bitmapData {- metadata -}, _) -> do 69 | bitmapSize bitmapData `shouldBe` (1800, 1280) 70 | 71 | pendingWith "Needs to be implemented upstream in Juicy.Pixels first" 72 | -- https://github.com/Twinside/Juicy.Pixels/issues/204 73 | -- or in hsexif: https://github.com/emmanueltouzery/hsexif/issues/19 74 | 75 | _ -> expectationFailure "File should have been loaded" 76 | 77 | it "converts an RGBA image to binary" $ do 78 | pictureMetadataEither <- loadImage "./images/doc.jpg" 79 | 80 | _ <- pictureMetadataEither `shouldSatisfy` P.isRight 81 | 82 | case pictureMetadataEither of 83 | Left _ -> pure () 84 | Right (Bitmap bitmapData, _metadata) -> do 85 | let 86 | width = P.fst bitmapData.bitmapSize 87 | height = P.snd bitmapData.bitmapSize 88 | withForeignPtr (castForeignPtr bitmapData.bitmapPointer) $ 89 | \ptr -> do 90 | resutlImg <- otsu_threshold_rgba width height False ptr 91 | resultImgForeignPtr <- newForeignPtr_ (castPtr resutlImg) 92 | let binaryPic = 93 | bitmapOfForeignPtr 94 | width 95 | height 96 | (BitmapFormat TopToBottom PxRGBA) 97 | resultImgForeignPtr 98 | True 99 | 100 | binaryPic 101 | `shouldSatisfy` \case 102 | Bitmap bmpData -> 103 | P.fst bmpData.bitmapSize == width 104 | && P.snd bmpData.bitmapSize == height 105 | _ -> False 106 | Right _ -> 107 | P.putText "Unsupported image format" 108 | 109 | describe "Rename" $ do 110 | it "renames files according to natural sort and avoids collisions" $ do 111 | let 112 | files = ["1.txt", "10.txt", "2.txt"] 113 | batches = 114 | [ 115 | [ ("1.txt", "0.txt") 116 | , ("2.txt", "_perspec_temp_1.txt") 117 | , ("10.txt", "_perspec_temp_2.txt") 118 | ] 119 | , 120 | [ ("_perspec_temp_1.txt", "1.txt") 121 | , ("_perspec_temp_2.txt", "2.txt") 122 | ] 123 | ] 124 | 125 | getRenamingBatches Nothing Sequential Ascending files 126 | `shouldBe` batches 127 | 128 | describe "Renaming files in descending order" $ do 129 | it "automatically sets first page number" $ do 130 | let 131 | files = ["1.txt", "10.txt", "2.txt"] 132 | batches = 133 | [ 134 | [ ("1.txt", "_perspec_temp_2.txt") 135 | , ("2.txt", "_perspec_temp_1.txt") 136 | , ("10.txt", "0.txt") 137 | ] 138 | , 139 | [ ("_perspec_temp_2.txt", "2.txt") 140 | , ("_perspec_temp_1.txt", "1.txt") 141 | ] 142 | ] 143 | 144 | getRenamingBatches Nothing Sequential Descending files 145 | `shouldBe` batches 146 | 147 | it "allows explicitly setting first page number" $ do 148 | let 149 | files = ["1.txt", "10.txt", "2.txt"] 150 | batches = 151 | [ 152 | [ ("1.txt", "_perspec_temp_2.txt") 153 | , ("2.txt", "_perspec_temp_1.txt") 154 | , ("10.txt", "0.txt") 155 | ] 156 | , 157 | [ ("_perspec_temp_2.txt", "2.txt") 158 | , ("_perspec_temp_1.txt", "1.txt") 159 | ] 160 | ] 161 | 162 | getRenamingBatches (Just 2) Sequential Descending files 163 | `shouldBe` batches 164 | 165 | describe "Renaming files with even page numbers" $ do 166 | let 167 | files = ["a.txt", "c.txt", "e.txt"] 168 | batchesStartingZero = 169 | [ 170 | [ ("a.txt", "0.txt") 171 | , ("c.txt", "2.txt") 172 | , ("e.txt", "4.txt") 173 | ] 174 | ] 175 | 176 | it "automatically sets first page number" $ do 177 | getRenamingBatches Nothing Even Ascending files 178 | `shouldBe` batchesStartingZero 179 | 180 | it "automatically sets first page number with descending order" $ do 181 | let 182 | numericFiles = ["8.txt", "10.txt", "9.txt"] 183 | batches = 184 | [ 185 | [ ("8.txt", "4.txt") 186 | , ("9.txt", "2.txt") 187 | , ("10.txt", "0.txt") 188 | ] 189 | ] 190 | 191 | getRenamingBatches Nothing Even Descending numericFiles 192 | `shouldBe` batches 193 | 194 | it "allows explicitly setting first page number" $ do 195 | getRenamingBatches (Just 0) Even Ascending files 196 | `shouldBe` batchesStartingZero 197 | 198 | it "rounds to next even page number" $ do 199 | let batches = 200 | [ 201 | [ ("a.txt", "2.txt") 202 | , ("c.txt", "4.txt") 203 | , ("e.txt", "6.txt") 204 | ] 205 | ] 206 | 207 | getRenamingBatches (Just 1) Even Ascending files 208 | `shouldBe` batches 209 | 210 | describe "Renaming files with odd page numbers" $ do 211 | it "correctly sets first page number" $ do 212 | let 213 | files = ["b.txt", "d.txt", "f.txt"] 214 | batches = 215 | [ 216 | [ ("b.txt", "1.txt") 217 | , ("d.txt", "3.txt") 218 | , ("f.txt", "5.txt") 219 | ] 220 | ] 221 | 222 | getRenamingBatches Nothing Odd Ascending files `shouldBe` batches 223 | getRenamingBatches (Just 0) Odd Ascending files `shouldBe` batches 224 | getRenamingBatches (Just 1) Odd Ascending files `shouldBe` batches 225 | 226 | it "works with descending order and automatically sets page number" $ do 227 | let 228 | files = ["8.txt", "10.txt", "9.txt"] 229 | batches = 230 | [ 231 | [ ("8.txt", "5.txt") 232 | , ("9.txt", "3.txt") 233 | , ("10.txt", "1.txt") 234 | ] 235 | ] 236 | 237 | getRenamingBatches Nothing Odd Descending files `shouldBe` batches 238 | 239 | it "works with descending order and explicit page number" $ do 240 | let 241 | files = ["8.txt", "10.txt", "9.txt"] 242 | batches = 243 | [ 244 | [ ("8.txt", "7.txt") 245 | , ("9.txt", "5.txt") 246 | , ("10.txt", "3.txt") 247 | ] 248 | ] 249 | 250 | getRenamingBatches (Just 7) Odd Descending files `shouldBe` batches 251 | getRenamingBatches (Just 8) Odd Descending files `shouldBe` batches 252 | 253 | it "prefixes pages with negative numbers with \"_todo_\"" $ do 254 | let 255 | files = ["8.txt", "10.txt", "9.txt"] 256 | batches = 257 | [ 258 | [ ("8.txt", "1.txt") 259 | , ("9.txt", "_todo_-1.txt") 260 | , ("10.txt", "_todo_-3.txt") 261 | ] 262 | ] 263 | 264 | getRenamingBatches (Just 1) Odd Descending files `shouldBe` batches 265 | -------------------------------------------------------------------------------- /test/example.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/test/example.afphoto -------------------------------------------------------------------------------- /test/example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-si/Perspec/bd1d164c10f995091e80e6d63b8d3cc02822b504/test/example.jpg -------------------------------------------------------------------------------- /usage.txt: -------------------------------------------------------------------------------- 1 | Perspec 2 | 3 | Correct the perspective of photos 4 | and rename them to match their page numbers. 5 | 6 | Usage: 7 | perspec gui [--backend=] 8 | perspec fix [--backend=] ... 9 | perspec rename [options] 10 | 11 | Options: 12 | --backend= Image manipulation backend to use 13 | (SimpleCV, Hip, ImageMagick) 14 | 15 | Rename Options: 16 | --start-with= First page number 17 | --even The directory contains only even page numbers 18 | --odd The directory contains only odd page numbers 19 | --descending Pages were photographed in descending order 20 | 21 | Notes: 22 | If page numbers go below zero, they are prefixed with "_todo_" 23 | --------------------------------------------------------------------------------