├── .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 |
6 | - base
7 | - brillo-juicy
8 | - brillo
9 | - directory
10 | - filepath
11 | - hsexif
12 | - process
13 | - protolude
14 | - text
15 |
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 |
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 |
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 |
--------------------------------------------------------------------------------