├── .gitignore ├── LICENSE ├── README.md ├── background ├── 1.jpg ├── 2.jpg └── 6.jpg ├── img ├── background-displace.png ├── backgrounds.png ├── demo.png ├── displacement-map.png ├── faded-text.png ├── final.png ├── font-kern.png ├── line-spacing.png ├── misspell-mask.png ├── misspell-strike.png ├── perspective.png ├── randomized-text.png ├── slant1.png ├── slant2.png ├── slant3.png └── white-rows.png ├── macro.py ├── out ├── test-1_edited.png ├── test-2_edited.png └── test_edited.pdf ├── test ├── test-1.png ├── test-2.png └── test.pdf └── writing_artifact.py /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | !.gitignore 3 | 4 | # Internal tests 5 | test2* 6 | 7 | writing_artifact.ipynb 8 | fonts/* 9 | test/*.odt 10 | background/* 11 | 12 | # Examples of background 13 | !background/1.jpg 14 | !background/2.jpg 15 | !background/6.jpg -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 sherlockdoyle 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Handwriter 2 | 3 | Make writing easier! 4 | ![Demo](img/demo.png) 5 | 6 | ## End of life 7 | 8 | This was a fun project, but I don't have the time to work on it anymore. Whatever has been implemented will remain, but I won't be adding new features, at least for now. Maybe I'll add some more cool features in the future. 9 | 10 | However, if you want to contribute, specially to complete the [TODO](#TODO)s below, feel free to fork the project and send me pull requests. 11 | 12 | ## How to 13 | 14 | 1. Download and install a [handwriting font](https://fonts.google.com/?category=Handwriting), or [create a font](#Creating-your-font) from your handwriting. 15 | 2. Use a word processor like *Microsoft Word* or *LibreOffice Writer* to write to your heart's content with the handwriting font. (Hint: Use colors. `#000000` for black, and `#082A5E` for blue works nicely.) 16 | 3. [Introduce some mistakes.](#Introducing-mistakes) 17 | 4. Save each page as an image. 18 | 19 | * Save directly as an image. I use a size of (1626*2300) and a resolution of 50 pixels/cm (*LibreOffice Writer*). 20 | * Save as a PDF and convert to PNGs. 21 | ```bash 22 | pdftoppm -png pdf_file.pdf image_name -scale-to-x 1626 -scale-to-y 2300 23 | ``` 24 | 5. Transform the images to handwriting style. Requires *numpy* and *OpenCV*. 25 | ```bash 26 | python3 writing_artifact.py /path/to/image_name-*.png 27 | ``` 28 | 29 | The images are saved (by default) in the `./out/` folder. 30 | 6. Change the edited images back to PDF. 31 | ```bash 32 | convert image_name-*_edited.png pdf_edited.pdf 33 | ``` 34 | 35 | ## Simulating handwritten text 36 | 37 | This is yet another project to create handwritten-style documents digitally. You may use this for **assignments**, among other things. Other similar projects/links include (in no particular order): 38 | 39 | * [MyHandWriting](https://github.com/bannyvishwas2020/MyhandWriting) 40 | * [Text to Handwriting](https://saurabhdaware.github.io/text-to-handwriting/) 41 | * [Recurrent Neural Network Handwriting Generation Demo](https://www.cs.toronto.edu/~graves/handwriting.html) 42 | * [calligrapher.ai](https://www.calligrapher.ai/) 43 | * [Handwriting Synthesis](https://github.com/sjvasquez/handwriting-synthesis) 44 | * [handLaTeX](https://github.com/DavideFauri/handlatex) 45 | * [Scriptalizer](https://www.scriptalizer.co.uk/scriptalizer.aspx) 46 | 47 | I prefer typing, like other like-minded individuals who might be interested in this project, or who know or uses the above links. And typing saves paper, saves trees, saves the planet; now I'm just being ambitious. What more, if you want to copy your answers from some source, not that I support cheating, typing is much easier (copy-paste). 48 | 49 | ### Why another project? 50 | 51 | This project was inspired by [MyHandWriting](https://github.com/bannyvishwas2020/MyhandWriting), or rather, the developer's [Reddit post](https://www.reddit.com/r/Python/comments/g5bbss/my_professor_wants_hand_written_assignments_so_i/). However, that, or other projects above support only plain text, without any formatting. For instance, what if you want a table or just multiple columns? 52 | 53 | This project achieves this in two steps. First, the properly formatted text is written with a word processor, and each page is saved as an image. Then, the images are transformed in handwritten style with 54 | ```bash 55 | python3 writing_artifact.py /path/to/pages.png 56 | ``` 57 | 58 | The code above simulates different handwriting artifacts and places the text on a white (A4, preferably) page. Several ideas were taken from the shortcomings of the above projects. This of course assumes that you don't have beautiful calligraphic handwriting. 59 | 60 | ## Steps taken 61 | 62 | The code uses *numpy* and *OpenCV* to do its job. Make sure to install them with `pip`. The following images were edited with GIMP, but the same thing is done with *OpenCV* in the code. The following images use the [Homemade Apple](https://fonts.google.com/specimen/Homemade+Apple) font, but I use my handwriting as a font. Using your handwriting gives better results. This code also makes several assumptions based on my writing style. 63 | 64 | ### Creating your font 65 | 66 | If you want to use your handwriting as a font, use a site like [Calligraphr](https://www.calligraphr.com/en/). You may create a randomized font, but that is not necessary; the code will [randomize the text](#Randomizing-text). Once you've downloaded the font, use software like [FontForge](https://fontforge.org/en-US/) to edit the kerning or left and right bearing. I prefer editing the kerning. If your handwriting is cursive and continuous, this step is necessary. 67 | 68 | To edit kerning for a font 69 | 70 | 1. Open the font with FontForge. 71 | 2. Go to *Metrics > New Metrics Window*. 72 | 3. Go to *Metrics > Window Type*, make sure that *Kerning only* is selected. 73 | 4. Click on the *New Lookup Subtable...* dropdown, click *New Lookup Subtable...* 74 | 1. Under the *Feature* column in the popup, click on the dropdown beside *<New>*. Select *kern Horizontal Kerning*. 75 | 2. Click *OK*. 76 | 3. Again click *OK* in the popup, rename the table if you want. 77 | 5. Type some common words. Drag the characters or edit the values in the *Kern* field. 78 | ![Kerning font](img/font-kern.png) 79 | Type other words and adjust kerning. You don't need to do this for all letter pairs; when using the font, if you find some discrepancies, edit the kerning accordingly. 80 | 6. Close the window. 81 | 7. Go to *File > Generate Fonts...* Navigate to the folder where to save, choose the file name and *Generate*. You may ignore any warnings. 82 | 83 | You may want to add other glyphs and tables to the font for ligatures, conditional substitution, etc. For instance, cursive uppercase letters not followed by a lowercase letter might look odd. In such cases, an alternate glyph might be suitable. As for me, I use a different font for these letters (see in [Introducing mistakes](#Introducing-mistakes)). 84 | 85 | Install the font. Clicking on the saved font will install it on most platforms. Re-edit the font file and (uninstall) reinstall when required. 86 | 87 | ### Write 88 | 89 | Use your favorite word processor to write whatever you want. Don't forget to use the font you just installed. Edit margins (usually reduce) to match your writing style. Make sure to use a white background and dark colors for writing. Do not use the red color as it is used to [mark mistakes](#Introducing-mistakes). The code makes these assumptions. If you're using tables, remove the borders. Change line spacing to 1.5 (recommended). 90 | 91 | ### Introducing mistakes 92 | 93 | Humans make spelling mistakes when writing, and strike out the mistakes. This is done by surrounding a misspelled word with a red border. The following steps add a red border in LibreOffice Writer. 94 | 95 | 1. Select a misspelled word. 96 | 2. *Right click > Character > Character...* 97 | 3. Go to *Borders*. 98 | 4. Select the second option in *Presets:* to add a border on all sides. 99 | 5. In *Padding*, set everything to 0 (optional). 100 | 6. Under *Line* in *Color:*, set to red (`#FF0000`). 101 | 102 | There's also a LibreOffice [macro](macro.py) available to perform these steps. The macro is limited, edit the code for variations. 103 | 104 | 1. Copy paste the macro in `~/.config/libreoffice/4/user/Scripts/python/`. Rename if you want. 105 | 2. Restart LibreOffice if needed. 106 | 3. Select all text where mistakes need to be inserted. 107 | 4. Go to *Tools > Macros > Organize Macros > Python...* 108 | 5. Expand *My Macros > name_of_macro_that_you_copied*, select `introduceMistakes` and click *Run*. 109 | 6. This will randomly add misspelled words with a red border. This will also change the font for those uppercase letters which are not followed by a lowercase letter. 110 | 111 | There are some known bugs/features. 112 | 113 | * It does not introduce mistakes in text inside objects, for instance, tables. For this, select the text inside each object (each table cell) and re-run the macro. 114 | * Sometimes (when there's an object at the end of a selection), you might see the `INTRODUCEMISTAKEENDMARKER` word. Delete it. 115 | 116 | [Make a menu entry](https://ask.libreoffice.org/en/question/156744/assign-macro-to-toolbar/) for easier access to the macro. 117 | 118 | ### Introduce artifacts 119 | 120 | Save the document as a PDF or images. If PDF, convert the pages to images with your preferred method. Now pass the folder with all the images as an argument to the code. You may also pass each image individually, and/or use wildcards. Note that, for wildcards, the code relies on the shell to expand the file names. 121 | 122 | The output images are saved as `_edited.`. By default, the image's original extension (format) is used. You can change the format with the `-f` or `--output-format` option. Since OpenCV is used to read and write image files, the extension must be one supported by your installation of OpenCV. By default, the output images are saved in the `./out/` directory. You can change this with the `-o` or `--out` option. 123 | 124 | Use `python3 writing_artifact.py -h` for help. Relevant options will be discussed below. 125 | 126 | ### Getting the mask 127 | 128 | As discussed earlier, the mistakes in the document are marked with a red border. The first step is to get these red rectangles as a mask, and the image without the red color. 129 | ![Misspelling mask](img/misspell-mask.png) 130 | You may notice slight artifacts where the red lines were present, a little bit of the text is missing. This is because this font uses negative bearings to simulate the cursive overlap. This can be prevented slightly if kerning is used instead. 131 | 132 | #### Preprocessing 133 | 134 | The original text is slightly thin, but that's how my handwriting font is. So there's an extra preprocessing step, which thickens the letters slightly and reduces sharp edges. 135 | 136 | ### Randomizing text 137 | 138 | As of now, all the letters look the same. However, in handwriting, there's some variation in every letter. One way is to use a randomized font. Alternately, we can randomly move the text a little with a noise map. 139 | ![Randomized text](img/randomized-text.png) 140 | There's two noise map here, one for each axis. The amount to move the text with these maps can be controlled with the `-r` option; `0` means no movement, other values move in the positive or negative direction. The scale of the noise map can be controlled with the `-s` option; higher values mean a higher frequency of the noise. Experiment with this to match your font size. 141 | 142 | ### Striking the mistakes 143 | 144 | Using the mask, some strikes are generated randomly and applied on top of the mistakes. The color of the strikes is automatically extracted from the surrounding texts. 145 | ![Misspelling strike](img/misspell-strike.png) 146 | The original strikes are generated with inverted colors for easier processing. The strikes are also cropped with a convex hull of the text. 147 | 148 | ### Getting lines of text 149 | 150 | We go through each row of pixels in the image and check if it only consists of white rows or other colors too. This is used to divide the image into alternating white and text areas. We also select some of the lines based on their length, line spacing, etc. 151 | ![White rows](img/white-rows.png) 152 | The red lines show the rows, the blue lines are the selected ones. Sometimes, if two lines are too close, they might be detected as a single line. Try changing the line height if so. 153 | 154 | ### Change line spacing 155 | 156 | In handwriting, we tend to have some lines closer to each other than others. In other words, we have varying line spacing. Using the rows of text found above, we move each line randomly up or down. We also move each line randomly left or right a little so that every line doesn't start from the same position (column). 157 | ![Line spacing](img/line-spacing.png) 158 | The amount to move the lines up or down in this step can be controlled with the `-t` option; `0` means no movement, other values move in the positive or negative direction. 159 | 160 | ### Slanting lines 161 | 162 | When writing, we tend to slant the lines (generally upwards). We select blocks of text based on the selected lines above (blue lines) and slant each of these blocks separately. Several different methods to slant the text is available and one of them is chosen randomly. For each of the blocks, either no slanting is done, or 163 | 164 | 1. Skew all the lines together. 165 | ![Skewed lines](img/slant1.png) 166 | 2. Skew each line by a different amount. The first line is not skewed, the second line is skewed a little, the third line is skewed a little more, and so on. 167 | ![Partially skewed lines](img/slant2.png) 168 | 3. Fake perspective transforms to shorten the right side of the text. 169 | ![Fake perspective transform](img/slant3.png) 170 | 171 | #### What's a fake perspective? 172 | 173 | With a normal perspective transform, we get the following. 174 | ![Perspective transform](img/perspective.png) 175 | Note how the columns on the top move to the right. This is not an error, but the expected behavior of perspective transform. However, we can't have this in our case since this will mess up column alignments, for instance, in tables. 176 | 177 | Instead, we use a vertical displacement map like the following to shrink the right side of the block. 178 | ![Vertical displacement map](img/displacement-map.png) 179 | This generates the slants as shown above with proper column alignment. The amount to slant the lines in this step can be controlled with the `-k` option; `0` means no movement, other values move in the positive (upward) or negative (downward) direction. 180 | 181 | ### Fading text 182 | 183 | When we write on a page, the text doesn't have the same opacity everywhere. Due to how the ink is soaked and dries, some text might appear lighter than the rest. Using a noise map, the text is faded randomly. 184 | ![Fading text](img/faded-text.png) 185 | This makes parts of the text lighter depending on the lightness of the noise map. The lowest opacity of faded text can be controlled with the `-a` option; `0` means part of the text is not visible at all, `1` means no fading (fully opaque). 186 | 187 | ### Backgrounds 188 | 189 | Once we've sufficiently applied handwriting effects to the text, we need to place it on top of an (A4) paper texture. By default, the background images are read from the `./background` folder. You can change this with the `-b` or `--background` option. 190 | 191 | To use your backgrounds, scan or take a picture of a white A4 paper. Use image editing software like GIMP to crop the image such that the page fills the whole image. You might also need to apply some perspective transformation and/or cage transformations to fit the image. Without this, the curves in the paper will make the output look unnatural. Place the edited images in your background folder. 192 | 193 | ### Reading backgrounds 194 | 195 | When a background image is read, some optional transformations are randomly applied to generate variations of the background. 196 | ![Background transformations](img/backgrounds.png) 197 | The original image and three other variations can be combined to produce 8 different backgrounds from a single background. Moreover, multiple backgrounds can be merged to generate even more backgrounds. Each of those backgrounds can have its variations. This allows generating a wide number of backgrounds from only a few original ones. 198 | 199 | When multiple images are passed to the code at once, one background for each of them is generated at a time while ensuring that each of them is unique. 200 | 201 | ### Merging text on background 202 | 203 | Once the background is read, it might have shadows. These shadows are used to further displace the image to give the effect that the text curves with the page. 204 | ![Background displacement](img/background-displace.png) 205 | Finally, the text is merged on the background, normalized, and saved. 206 | ![Final image](img/final.png) 207 | Here's how it looks before and after (old picture, refer to the [top](#Handwriter) for how the latest version of the software performs). If you use your handwriting, it'll look more realistic. 208 | 209 | ## TODO 210 | 211 | * [ ] Detect images (line drawing) and apply low-frequency noise to them to give a hand-drawn effect. 212 | * [ ] Better strike out for mistakes. 213 | * [ ] Second page's background will be the flipped variant of the first. 214 | * [ ] The second page will have flipped bleed through text from the first page. 215 | * [X] Each line will have random horizontal movement. 216 | -------------------------------------------------------------------------------- /background/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/background/1.jpg -------------------------------------------------------------------------------- /background/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/background/2.jpg -------------------------------------------------------------------------------- /background/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/background/6.jpg -------------------------------------------------------------------------------- /img/background-displace.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/img/background-displace.png -------------------------------------------------------------------------------- /img/backgrounds.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/img/backgrounds.png -------------------------------------------------------------------------------- /img/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/img/demo.png -------------------------------------------------------------------------------- /img/displacement-map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/img/displacement-map.png -------------------------------------------------------------------------------- /img/faded-text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/img/faded-text.png -------------------------------------------------------------------------------- /img/final.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/img/final.png -------------------------------------------------------------------------------- /img/font-kern.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/img/font-kern.png -------------------------------------------------------------------------------- /img/line-spacing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/img/line-spacing.png -------------------------------------------------------------------------------- /img/misspell-mask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/img/misspell-mask.png -------------------------------------------------------------------------------- /img/misspell-strike.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/img/misspell-strike.png -------------------------------------------------------------------------------- /img/perspective.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/img/perspective.png -------------------------------------------------------------------------------- /img/randomized-text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/img/randomized-text.png -------------------------------------------------------------------------------- /img/slant1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/img/slant1.png -------------------------------------------------------------------------------- /img/slant2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/img/slant2.png -------------------------------------------------------------------------------- /img/slant3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/img/slant3.png -------------------------------------------------------------------------------- /img/white-rows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/img/white-rows.png -------------------------------------------------------------------------------- /macro.py: -------------------------------------------------------------------------------- 1 | import random 2 | import uno 3 | 4 | 5 | avg_mistake_dist = 20 # average number of words between two misspelled words 6 | border_color = 0xff0000 # border color around misspelled word 7 | single_char_font = 'Hand1' # font name of single letter words 8 | 9 | end_marker = 'INTRODUCEMISTAKEENDMARKER' 10 | # It seems that there's no easy way to get a cursor spanning a selection. So this word is temporarily inserted at the 11 | # end of the selection. 12 | 13 | 14 | def introduceMistakes(): 15 | doc = XSCRIPTCONTEXT.getDocument() 16 | selections = doc.getCurrentController().getSelection() 17 | 18 | for i in range(selections.Count): 19 | block = selections.getByIndex(i) 20 | block.Text.insertString(block.getEnd(), ' '+end_marker+' ', True) # insert the marker 21 | 22 | cursor = block.Text.createTextCursorByRange(block) 23 | if cursor.isStartOfWord(): 24 | cursor.goLeft(0, False) 25 | else: 26 | cursor.gotoNextWord(False) 27 | 28 | while True: 29 | cursor.gotoEndOfWord(True) 30 | if cursor.String == end_marker: 31 | # remove the marker and break out, since reached end of selection 32 | cursor.goRight(1, True) 33 | cursor.String = '' 34 | cursor.goLeft(1, True) 35 | cursor.String = '' 36 | break 37 | 38 | # comment out the following line to disable changing the font for uppercase letters 39 | fix_upper(cursor) 40 | 41 | if random.randint(0, avg_mistake_dist) == 0: 42 | word = cursor.String 43 | mword = wrong(word) 44 | 45 | if not word.startswith(mword): 46 | cursor.String = mword+' '+word 47 | mcur = cursor.Text.createTextCursorByRange(cursor) 48 | mcur.goLeft(0, False) 49 | mcur.goRight(len(mword), True) 50 | 51 | border = uno.createUnoStruct('com.sun.star.table.BorderLine2', 52 | Color=border_color, 53 | OuterLineWidth=2, 54 | LineWidth=2) 55 | mcur.setPropertyValue('CharLeftBorder', border) 56 | mcur.setPropertyValue('CharTopBorder', border) 57 | mcur.setPropertyValue('CharRightBorder', border) 58 | mcur.setPropertyValue('CharBottomBorder', border) 59 | 60 | if not cursor.gotoNextWord(False): 61 | break 62 | 63 | 64 | # similar set of letters 65 | vowels = list('aeiou') 66 | c21 = list('aceo') 67 | c22 = list('imnrsuvwxz') 68 | c12 = list('bdhklt') 69 | c23 = list('gjpqy') 70 | subs = (vowels, c21, c22, c12, c23) 71 | swaps = ('ie', 'ei') 72 | end_sub = { 73 | 'able': 'ible', 74 | 'ible': 'able', 75 | 'ance': 'ence', 76 | 'ence': 'ance', 77 | 'ceed': 'seed', 78 | 'cede': 'sede', 79 | 'ery': 'ary', 80 | 'ary': 'ery', 81 | 'ent': 'ant', 82 | 'ant': 'ent', 83 | 'eed': 'ede', 84 | 'lly': 'ly', 85 | 'eur': 'er', 86 | 'al': 'el', 87 | 'el': 'al', 88 | 'te': 't', 89 | 'mn': 'm', 90 | 'll': 'l', 91 | 'l': 'll' 92 | } 93 | mid_sub = { 94 | 'sc': 'ch', 95 | 'te': 'ght', 96 | 'ght': 'te', 97 | 'ate': 'eat', 98 | 'eat': 'ate', 99 | 'ten': 'tain', 100 | 'nun': 'noun', 101 | 'tain': 'ten' 102 | } 103 | full_sub = { 104 | 'equipment': ['equiptment'], 105 | 'accommodate': ['acommodate', 'accomodate'], 106 | 'acknowledgment': ['acknowledgement'], 107 | 'acquire': ['aquire'], 108 | 'apparent': ['apparant', 'aparent', 'apparrent', 'aparrent'], 109 | 'calendar': ['calender'], 110 | 'colleague': ['collaegue', 'collegue', 'coleague'], 111 | 'conscientious': ['consciencious'], 112 | 'consensus': ['concensus'], 113 | 'entrepreneur': ['entrepeneur', 'entreprenur', 'entreperneur'], 114 | 'fulfill': ['fulfil'], 115 | 'indispensable': ['indispensible'], 116 | 'led': ['lead'], 117 | 'laid': ['layed'], 118 | 'liaison': ['liasion'], 119 | 'license': ['licence', 'lisence'], 120 | 'maintenance': ['maintainance', 'maintnance'], 121 | 'necessary': ['neccessary', 'necessery'], 122 | 'occasion': ['occassion'], 123 | 'occurred': ['occured'], 124 | 'pastime': ['pasttime'], 125 | 'privilege': ['privelege', 'priviledge'], 126 | 'publicly': ['publically'], 127 | 'receive': ['recieve'], 128 | 'recommend': ['recomend', 'reccommend'], 129 | 'referred': ['refered'], 130 | 'relevant': ['relevent', 'revelant'], 131 | 'separate': ['seperate'], 132 | 'successful': ['succesful', 'successfull', 'sucessful'], 133 | 'underrate': ['underate'], 134 | 'until': ['untill'], 135 | 'withhold': ['withold'] 136 | } 137 | 138 | 139 | def wrong(word: str): 140 | chars = list(word) 141 | l = len(chars) # used to optionally remove few characters from the end 142 | 143 | # double to single 144 | i = 1 145 | while i < l: 146 | if chars[i] == chars[i-1] and random.random() < 3/len(chars): 147 | chars.pop(i) 148 | l -= 1 149 | else: 150 | i += 1 151 | 152 | # swap characters 153 | for i in range(1, len(chars), 2): 154 | c1, c2 = chars[i-1], chars[i] 155 | for o1, o2 in swaps: 156 | if c1 == o1 and c2 == o2 and random.random() < 3/len(chars): 157 | chars[i-1], chars[i] = chars[i], chars[i-1] 158 | 159 | # real english mistakes 160 | word = ''.join(chars) 161 | # full replace 162 | if word in full_sub and random.random() < 0.75: 163 | return random.choice(full_sub[word]) 164 | # replace end 165 | for k, v in end_sub.items(): 166 | if word.endswith(k): 167 | return word[:-len(k)] + v 168 | # replace middle 169 | for k, v in mid_sub.items(): 170 | try: 171 | idx = word.index(k) 172 | return word[:idx] + v + word[idx+len(k):] 173 | except ValueError: 174 | pass 175 | 176 | # random mistakes 177 | t = random.randint(0, 4) 178 | if t == 0: # replace characters 179 | for replacor in subs: 180 | for i in range(len(chars)): 181 | if chars[i].lower() in replacor and random.random() < .33: 182 | nc = random.choice(replacor) 183 | chars[i] = nc if chars[i].islower() else nc.upper() 184 | if random.random() < .5: 185 | l = i 186 | break 187 | elif t == 1: # swap characters 188 | for i in range(1, len(chars)-1): 189 | if random.random() < 2/len(chars): 190 | chars[i], chars[i+1] = chars[i+1], chars[i] 191 | if random.random() < .5: 192 | l = i+1 193 | return ''.join(chars[:l]) 194 | 195 | 196 | def fix_upper(cursor): 197 | """Change font for upper case letters which are not followed by lower case letter.""" 198 | l = len(cursor.String) 199 | for i in range(l): 200 | if (i == l-1 or not cursor.String[i+1].islower()) and cursor.String[i].isupper(): 201 | cur = cursor.Text.createTextCursorByRange(cursor) 202 | cur.goLeft(l-i, False) 203 | cur.goRight(1, True) 204 | cur.CharFontName = single_char_font 205 | -------------------------------------------------------------------------------- /out/test-1_edited.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/out/test-1_edited.png -------------------------------------------------------------------------------- /out/test-2_edited.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/out/test-2_edited.png -------------------------------------------------------------------------------- /out/test_edited.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/out/test_edited.pdf -------------------------------------------------------------------------------- /test/test-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/test/test-1.png -------------------------------------------------------------------------------- /test/test-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/test/test-2.png -------------------------------------------------------------------------------- /test/test.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sherlockdoyle/Handwriter/9d54fa0c1e0b0ac048ddf9187a390ee5319742d1/test/test.pdf -------------------------------------------------------------------------------- /writing_artifact.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple, Union, List 2 | from collections import namedtuple 3 | import os 4 | import argparse 5 | import numpy as np 6 | import cv2 7 | 8 | image = imgRGB = imgGray = np.ndarray 9 | """imgRGB and imgGray refers to 3 and 2 dimension arrays respectively. image is either.""" 10 | 11 | 12 | def imshow(*img: image, scale: float = 2.5): 13 | """Utility method to display one or more images.""" 14 | for i, im in enumerate(img): 15 | cv2.imshow( 16 | f'image{i}', 17 | cv2.resize(im, (int(im.shape[1]/scale), int(im.shape[0]/scale))) 18 | ) 19 | while cv2.waitKey(0) != 27: 20 | continue 21 | cv2.destroyAllWindows() 22 | 23 | 24 | def remove_holes(img: image, size: int = 5) -> image: 25 | """Removes artifacts/noises from image.""" 26 | kernel = np.ones((size, size), np.uint8) 27 | dilated = cv2.dilate(img, kernel) 28 | return cv2.erode(dilated, kernel) 29 | 30 | 31 | def flood_fill(img: image, fill_color: Union[int, Tuple[int, int, int]] = None) -> image: 32 | """Flood fill areas in an image.""" 33 | if fill_color is None: 34 | fill_color = 255 if img.ndim == 2 else (255, 255, 255) 35 | copy = img.copy() 36 | cv2.floodFill(copy, None, (0, 0), fill_color) 37 | return cv2.bitwise_or( 38 | img, 39 | cv2.bitwise_not(copy) 40 | ) 41 | 42 | 43 | def extract_mask(img: imgRGB, mask_hue_range: Tuple[float, float] = (-10, 10)) -> Tuple[imgGray, imgRGB]: 44 | """Extract masks from image and return the mask and original image without mask.""" 45 | hsv_img = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) 46 | color_mask = cv2.inRange(hsv_img, (mask_hue_range[0], 200, 200), (mask_hue_range[1], 255, 255)) 47 | if mask_hue_range[0] < 0: # special case, negative range 48 | color_mask |= cv2.inRange(hsv_img, (180+mask_hue_range[0], 200, 200), (180, 255, 255)) 49 | mask = cv2.dilate(color_mask, np.ones((2, 2), np.uint8)) 50 | orig = cv2.bitwise_or(img, cv2.merge((mask, mask, mask))) 51 | return flood_fill(remove_holes(mask)), orig 52 | 53 | 54 | def preprocess(img: imgRGB) -> imgRGB: 55 | """Preprocess (thicken text) the image.""" 56 | eroded = cv2.erode(img, np.ones((2, 2), np.uint8)) 57 | return cv2.addWeighted(img, 0.5, eroded, 0.5, 0) 58 | 59 | 60 | def mask_image(img: imgRGB, mask: imgGray) -> imgRGB: 61 | """Apply mask to an image and extract text.""" 62 | mask = cv2.merge((mask, mask, mask)) 63 | return cv2.bitwise_and( 64 | cv2.bitwise_not( 65 | cv2.bitwise_and(img, mask) 66 | ), 67 | mask 68 | ) 69 | 70 | 71 | def extract_contours(img: image) -> List[np.ndarray]: 72 | """Extract contours from image.""" 73 | contours, _ = cv2.findContours( 74 | cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if img.ndim == 3 else img, 75 | cv2.RETR_EXTERNAL, 76 | cv2.CHAIN_APPROX_NONE 77 | ) 78 | return contours 79 | 80 | 81 | def dilate_contours(contours: List[np.ndarray], w: int, h: int) -> List[np.ndarray]: 82 | """Dilate contours of image.""" 83 | black = np.zeros((h, w), np.uint8) 84 | cv2.drawContours(black, contours, -1, 255, -1) 85 | contours, _ = cv2.findContours( 86 | cv2.dilate(black, np.ones((7, 7), np.uint8)), 87 | cv2.RETR_EXTERNAL, 88 | cv2.CHAIN_APPROX_NONE 89 | ) 90 | return contours 91 | 92 | 93 | # def erode_contours(contours: List[np.ndarray], w: int, h: int) -> List[np.ndarray]: 94 | # """Erode contours of image.""" 95 | # black = np.zeros((h, w), np.uint8) 96 | # cv2.drawContours(black, contours, -1, 255, -1) 97 | # contours, _ = cv2.findContours( 98 | # cv2.erode(black, np.ones((2, 2), np.uint8)), 99 | # cv2.RETR_EXTERNAL, 100 | # cv2.CHAIN_APPROX_NONE 101 | # ) 102 | # return contours 103 | 104 | 105 | def get_hull_and_rect(contours: List[np.ndarray]) -> Tuple[List[np.ndarray], List[Tuple[int, int, int, int]]]: 106 | """Return the convex hull and bounding box of the counters.""" 107 | hull = [cv2.convexHull(cnt) for cnt in contours] 108 | rect = [cv2.boundingRect(cnt) for cnt in hull] 109 | return hull, rect 110 | 111 | 112 | def get_avg_color(img: imgRGB) -> Tuple[int, int, int]: 113 | """Get an image's average color.""" 114 | mask = cv2.threshold( 115 | cv2.cvtColor(img, cv2.COLOR_BGR2GRAY), 116 | 225, 255, cv2.THRESH_BINARY 117 | )[1] 118 | return cv2.mean(img, mask=mask)[:3] 119 | 120 | 121 | def get_strikes(rect: List[Tuple[int, int, int, int]], w: int, h: int, refImg: imgRGB) -> imgRGB: 122 | """Get the strikes to be put on top of wrong text. refImg is used to extract color.""" 123 | strikes = np.zeros((h, w, 3), np.uint8) 124 | if not rect: 125 | return strikes 126 | max_width = max(w for _, _, w, _ in rect) 127 | min_width = min(w for _, _, w, _ in rect) 128 | for x, y, w, h in rect: 129 | strike_color = get_avg_color(refImg[y:y+h, x:x+w]) 130 | # One line in the middle 131 | cv2.line(strikes, (x, y + h // 2), (x + w, y + h // 2), strike_color, 2) 132 | d = max_width - min_width 133 | d = 2 if d == 0 else 3 * (w - min_width) / d + 2 134 | n = np.random.randint(1, d) 135 | for i in range(n): 136 | l = (x, np.random.randint(y, y + h)) 137 | r = (x + w, np.random.randint(y, y + h)) 138 | cv2.line(strikes, l, r, strike_color, 2) 139 | return cv2.blur(strikes, (2, 2)) 140 | 141 | 142 | def put_strikes(img: imgRGB, strike: imgRGB, hull: List[np.ndarray]) -> imgRGB: 143 | """Put the strikes on top of image.""" 144 | black = np.zeros(img.shape[:2], np.uint8) 145 | cv2.drawContours(black, hull, -1, 255, -1) 146 | return cv2.bitwise_and( 147 | img, 148 | cv2.bitwise_not( 149 | cv2.bitwise_and( 150 | strike, 151 | cv2.merge((black, black, black)) 152 | ) 153 | ) 154 | ) 155 | 156 | 157 | def perlin(shape, res=(64, 64)) -> imgGray: 158 | """Generate a perlin noise image.""" 159 | # TODO: Where did I find this code from? 160 | orig_shape = shape 161 | shape = np.ceil(shape[0] / res[0]) * res[0], np.ceil(shape[1] / res[1]) * res[1] 162 | 163 | d0, d1 = shape[0] // res[0], shape[1] // res[1] 164 | angles = 2 * np.pi * np.random.rand(res[0] + 1, res[1] + 1) 165 | grad = np.dstack((np.cos(angles), np.sin(angles))) 166 | grid = np.mgrid[:res[0]:res[0] / shape[0], :res[1]:res[1] / shape[1]].transpose(1, 2, 0) % 1 167 | 168 | n00 = np.sum(np.dstack((grid[:, :, 0], grid[:, :, 1])) * grad[:-1, :-1].repeat(d0, 0).repeat(d1, 1), 2) 169 | n10 = np.sum(np.dstack((grid[:, :, 0] - 1, grid[:, :, 1])) * grad[1:, :-1].repeat(d0, 0).repeat(d1, 1), 2) 170 | n01 = np.sum(np.dstack((grid[:, :, 0], grid[:, :, 1] - 1)) * grad[:-1, 1:].repeat(d0, 0).repeat(d1, 1), 2) 171 | n11 = np.sum(np.dstack((grid[:, :, 0] - 1, grid[:, :, 1] - 1)) * grad[1:, 1:].repeat(d0, 0).repeat(d1, 1), 2) 172 | 173 | t = 6 * grid ** 5 - 15 * grid ** 4 + 10 * grid ** 3 174 | n0 = (1 - t[:, :, 0]) * n00 + t[:, :, 0] * n10 175 | n1 = (1 - t[:, :, 0]) * n01 + t[:, :, 0] * n11 176 | return ( 177 | np.sqrt(2) * ((1 - t[:, :, 1]) * n0 + t[:, :, 1] * n1) 178 | )[:orig_shape[0], :orig_shape[1]].astype(np.float32) 179 | 180 | 181 | def displace_image(img: imgRGB, mapx: imgGray, mapy: imgGray, fill: Tuple[int, int, int] = (255, 255, 255)) -> imgRGB: 182 | """Apply displacement map to an image.""" 183 | gridx, gridy = np.meshgrid(np.arange(img.shape[1], dtype=np.float32), 184 | np.arange(img.shape[0], dtype=np.float32)) 185 | if mapx is None: 186 | mapx = gridx 187 | else: 188 | mapx += gridx 189 | if mapy is None: 190 | mapy = gridy 191 | else: 192 | mapy += gridy 193 | 194 | return cv2.remap(img, mapx, mapy, cv2.INTER_CUBIC, borderMode=cv2.BORDER_CONSTANT, borderValue=fill) 195 | 196 | 197 | def get_white_rows(img: imgRGB) -> Tuple[List[int], imgGray]: 198 | """Get rows which are a boundary between white rows and text lines. Also return the internal binary image.""" 199 | rows = [0] # Initial header white section 200 | is_white = True 201 | bin_img = cv2.threshold(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY), 55, 255, cv2.THRESH_BINARY)[1] 202 | row_sum = bin_img.shape[1] * 255 203 | for i, row in enumerate(bin_img): 204 | new_iswhite = row.sum() >= row_sum 205 | if new_iswhite != is_white: 206 | rows.append(i) 207 | is_white = new_iswhite 208 | return rows, bin_img 209 | 210 | 211 | def get_n_shortest_line_idx(img: imgGray, white_rows: List[int], n: int) -> List[int]: 212 | """Return the index of the n shortest lines in img. First two lines are ignored.""" 213 | widths = [] 214 | l = len(white_rows) 215 | for i in range(5, l, 2): # ignore first two 216 | seg = img[white_rows[i]:white_rows[i+1]] 217 | top_height = white_rows[i] - white_rows[i-1] 218 | down_height = (white_rows[i+2] if i+2 < l else img.shape[0]) - white_rows[i+1] 219 | widths.append(( 220 | cv2.boundingRect( 221 | np.concatenate(extract_contours(255 - seg)) 222 | )[2] * top_height / down_height, 223 | i // 2 224 | )) 225 | widths.sort() 226 | breaks = sorted(i for e, i in widths[:n]) 227 | if breaks[-1] != len(widths) + 1: # last line shouldn't be the start 228 | breaks.append(len(widths) + 1) 229 | return breaks 230 | 231 | 232 | def draw_rows(img: imgRGB, rows: List[int], small_lines: List[int] = None) -> imgRGB: 233 | """Utility method to draw the text rows on the image.""" 234 | w = img.shape[1] 235 | rowed_img = img.copy() 236 | for r in rows: 237 | cv2.line(rowed_img, (0, r), (w, r), (0, 0, 255), 1) 238 | if small_lines is not None: 239 | for l in small_lines: 240 | r = rows[l*2+2] 241 | cv2.line(rowed_img, (0, r), (w, r), (255, 0, 0), 2) 242 | return rowed_img 243 | 244 | 245 | def perform_moves(img: imgRGB, w: int, rows: List[int], f: float = 1) -> imgRGB: 246 | """Move each line vertically and horizontally.""" 247 | white = np.ones_like(img)*255 248 | l = len(rows) 249 | white[rows[1]:rows[2]] = img[rows[1]:rows[2]] 250 | white[rows[-2]:rows[-1]] = img[rows[-2]:rows[-1]] 251 | for i in range(3, l-2, 2): # ignore first and last line 252 | shiftY = 0 253 | t = np.random.randint(3) 254 | if t == 1: 255 | shiftY = -np.random.randint((rows[i]-rows[i-1])/2) 256 | elif t == 2: 257 | shiftY = np.random.randint((rows[i+2]-rows[i+1])/2) 258 | shiftY = round(shiftY * f) 259 | shiftX = np.random.randint(-w/163, w/163+1) 260 | if shiftX > 0: 261 | white[rows[i]+shiftY:rows[i+1]+shiftY, :-shiftX] &= img[rows[i]:rows[i+1], shiftX:] 262 | elif shiftX < 0: 263 | white[rows[i]+shiftY:rows[i+1]+shiftY, -shiftX:] &= img[rows[i]:rows[i+1], :shiftX] 264 | else: 265 | white[rows[i]+shiftY:rows[i+1]+shiftY] &= img[rows[i]:rows[i+1]] 266 | rows[i] += shiftY 267 | rows[i+1] += shiftY 268 | return white 269 | 270 | 271 | def slant_block(img: imgRGB, row1: int, row2: int, shift: int, dst: imgRGB): 272 | """Slant row1 to row2 upwards in img and put in dst.""" 273 | w = img.shape[1] 274 | h = row2 - row1 275 | seg = img[row1:row2] 276 | matrix = cv2.getPerspectiveTransform(np.float32([[0, 0], [w, 0], [w, h], [0, h]]), 277 | np.float32([[0, shift], [w, 0], [w, h], [0, h + shift]])) 278 | slanted = cv2.warpPerspective(seg, matrix, (w, h + shift), 279 | borderMode=cv2.BORDER_CONSTANT, borderValue=(255, 255, 255)) 280 | dst[row1 - shift:row2, 0:w, :] &= slanted 281 | 282 | 283 | def slant_pers(img: imgRGB, row1: int, row2: int, shift: int, dst: imgRGB): 284 | """Slant row1 to row2 with fake perspective, ie. only compresses the right side.""" 285 | w = img.shape[1] 286 | h = row2 - row1 287 | seg = img[row1:row2] 288 | disp = np.zeros((h, w), dtype=np.float32) 289 | for i in range(h): 290 | for j in range(w): 291 | s = j/w*shift 292 | disp[i, j] = s*i/h 293 | dst[row1:row2] &= displace_image(seg, None, disp) 294 | 295 | 296 | def slant_lines(img: imgRGB, idx1: int, idx2: int, rows: List[int], shift: int, dst: imgRGB): 297 | """Slant rows from index idx1 to idx2, with each row slanted a little more than the one above.""" 298 | iw = idx2-idx1 299 | for j in range(iw): 300 | slant_block(img, rows[(idx1+j)*2+1], rows[(idx1+j+1)*2], round(j/(iw-1)*shift), dst) 301 | 302 | 303 | def perform_slants(img: imgRGB, lines: List[int], rows: List[int], f: float = 1) -> imgRGB: 304 | """Slant blocks of lines of the image.""" 305 | white = np.ones_like(img)*255 306 | start = 0 307 | for i in lines: 308 | idx1 = rows[start*2+1] 309 | idx2 = rows[i*2+2] 310 | idx1_1 = rows[start*2] 311 | idx2_1 = rows[i*2+1] 312 | idx2_2 = rows[i*2] 313 | prob = np.array([1, 2, 3, 1]) 314 | t = 4 if i-start <= 2 else np.random.choice(list(range(4)), p=prob/prob.sum()) 315 | if t == 0: 316 | slant_block(img, idx1, idx2, round(min(idx1-idx1_1, idx2-idx2_1)*f), white) 317 | elif t == 1: 318 | slant_pers(img, idx1, idx2, round((idx2-(idx2_1+idx2_2)//2)*f), white) 319 | elif t == 2: 320 | slant_lines(img, start, i+1, rows, round((idx2-idx2_2)*f), white) 321 | else: 322 | white[idx1:idx2] &= img[idx1:idx2] 323 | start = i+1 324 | return white 325 | 326 | 327 | def put_fading(img: imgRGB, fade: imgGray, f: float = 0.5) -> imgRGB: 328 | fade -= fade.min() 329 | fade /= fade.max() 330 | # fade = 1-(1-fade)**2 331 | fade += (1-fade) * f 332 | return (255 - (255-img) * fade.reshape((fade.shape[0], fade.shape[1], 1))).astype(np.uint8) 333 | 334 | 335 | background_code = namedtuple('background_code', ['path_idx', 'merges', 'resize', 'rotate', 'flip']) 336 | 337 | 338 | def get_background_codes(n: int, back_dir: str) -> Tuple[List[str], List[background_code]]: 339 | """Return a list of background paths and n unique background codes.""" 340 | paths = list(map( 341 | lambda p: os.path.join(back_dir, p), 342 | os.listdir(back_dir) 343 | )) 344 | backgrounds = set() 345 | while len(backgrounds) < n: 346 | backgrounds.add(background_code( 347 | path_idx=np.random.randint(len(paths)), # index of path 348 | merges=np.random.randint(len(paths)), # number of other backgrounds to merge with 349 | resize=np.random.rand() < 0.5, # resize the background from (W, H) to (H, W) 350 | rotate=np.random.rand() < 0.5, # rotate the background by 180 degrees 351 | flip=np.random.rand() < 0.5 # flip the background 352 | )) 353 | return paths, list(backgrounds) 354 | 355 | 356 | def get_back(code: background_code, paths: List[str], size: Tuple[int, int]) -> imgRGB: 357 | """Return a background for the code of given size.""" 358 | back = cv2.resize( 359 | cv2.imread(paths[code.path_idx]), 360 | size 361 | ) 362 | if code.merges: 363 | back2 = get_back(background_code( 364 | path_idx=np.random.randint(len(paths)), 365 | merges=code.merges-1, # one less merge 366 | resize=np.random.rand() < 0.5, 367 | rotate=np.random.rand() < 0.5, 368 | flip=np.random.rand() < 0.5 369 | ), paths, size) 370 | h, s, v = cv2.split( 371 | cv2.cvtColor((back * (back2/255)).astype(np.uint8), cv2.COLOR_BGR2HSV) 372 | ) 373 | x, y = v.min(), v.max() 374 | val = np.random.randint(50, 128) 375 | back = cv2.cvtColor( 376 | cv2.merge((h, s, ((v-x)/(y-x)*(255-val-x)+x+val).astype(np.uint8))), 377 | cv2.COLOR_HSV2BGR 378 | ) 379 | if code.resize: 380 | back = cv2.rotate(cv2.resize(back, back.shape[:2]), cv2.ROTATE_90_CLOCKWISE) 381 | if code.rotate: 382 | back = cv2.rotate(back, cv2.ROTATE_180) 383 | if code.flip: 384 | back = cv2.flip(back, 0) 385 | return back 386 | 387 | 388 | def do_artifact(img: imgRGB, back: imgRGB, *, 389 | text_shift_scale: int = 64, 390 | text_shift_factor: float = 5.5, 391 | line_slant_factor: float = 1, 392 | line_move_factor: float = 1, 393 | text_fade_factor: float = 0.5 394 | ) -> imgRGB: 395 | """Add the handwritten text artifacts.""" 396 | H, W, _ = img.shape 397 | mask, orig = extract_mask(img) 398 | orig = preprocess(orig) 399 | img_dispx = perlin((H, W), (text_shift_scale, text_shift_scale)) 400 | img_dispy = perlin((H, W), (text_shift_scale, text_shift_scale)) 401 | disp_img = displace_image(orig, -0.363636364*text_shift_factor*img_dispx, text_shift_factor*img_dispy) 402 | mistake_masked = mask_image(disp_img, mask) 403 | contours = extract_contours(mistake_masked) 404 | big_contours = dilate_contours(contours, W, H) 405 | hull, rect = get_hull_and_rect(big_contours) 406 | strikes = get_strikes(rect, W, H, mistake_masked) 407 | disp_strikes = displace_image(strikes, None, perlin((H, W), (16, 16))*7, (0, 0, 0)) 408 | striked_img = put_strikes(disp_img, disp_strikes, hull) 409 | rows, bin_img = get_white_rows(striked_img) 410 | print('Found', len(rows)//2, 'lines in image.') 411 | small_lines = get_n_shortest_line_idx(bin_img, rows, np.random.randint( 412 | max(len(rows)//12-3, 1), 413 | max(len(rows)//12+1, 3) 414 | )) 415 | # imshow(draw_rows(striked_img, rows, small_lines)) 416 | moved_img = perform_moves(striked_img, W, rows, line_move_factor) 417 | slanted_img = perform_slants(moved_img, small_lines, rows, line_slant_factor) 418 | faded_img = put_fading(slanted_img, perlin((H, W), (text_shift_scale, text_shift_scale)), text_fade_factor) 419 | norm_back = cv2.normalize( 420 | cv2.cvtColor(back, cv2.COLOR_BGR2GRAY), 421 | None, alpha=0, beta=1, norm_type=cv2.NORM_MINMAX, dtype=cv2.CV_32F 422 | ) 423 | page_morphed_img = displace_image(faded_img, None, 40-60*norm_back) 424 | on_page_img = cv2.normalize( 425 | (back * (page_morphed_img/255)).astype(np.uint8), 426 | None, alpha=0, beta=255, norm_type=cv2.NORM_MINMAX 427 | ) 428 | return on_page_img 429 | 430 | 431 | parser = argparse.ArgumentParser(description='Generate handwritten like text.', 432 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) 433 | parser.add_argument('images', nargs='+', help='path to images or a directory of images of text') 434 | parser.add_argument('-o', '--out', default='./out', help='path to output directory', metavar='DIR') 435 | parser.add_argument('-f', '--output-format', help='format (extension) of output images', metavar='EXT') 436 | parser.add_argument('-b', '--background', default='./background', 437 | help='path to directory with background images', metavar='DIR') 438 | parser.add_argument('--seed', type=int, help='seed for random number generator, used if specified', metavar='VAL') 439 | parser.add_argument('-s', default=64, type=int, help='scale of text shift', metavar='VAL') 440 | parser.add_argument('-r', default=5.5, type=float, help='amount to shift the text randomly', metavar='VAL') 441 | parser.add_argument('-k', default=1, type=float, 442 | help='amount to slant lines, 0 means no slant, positive means upwards, negative means downwards', metavar='VAL') 443 | parser.add_argument('-t', default=1, type=float, help='amount to move lines up or down', metavar='VAL') 444 | parser.add_argument('-a', default=0.5, type=float, help='lowest opacity for fading text', metavar='VAL') 445 | args = parser.parse_args() 446 | 447 | if args.seed is not None: 448 | np.random.seed(args.seed) 449 | 450 | image_paths = [] 451 | for path in args.images: 452 | if os.path.isdir(path): 453 | file_names = os.listdir(path) 454 | for fn in file_names: 455 | file_path = os.path.join(path, fn) 456 | if not os.path.isdir(file_path): 457 | image_paths.append(file_path) 458 | else: 459 | image_paths.append(path) 460 | 461 | num_images = len(image_paths) 462 | print(f'Will process {num_images} images.') 463 | background_paths, background_codes = get_background_codes(len(image_paths), args.background) 464 | if not os.path.exists(args.out): 465 | os.mkdir(args.out) 466 | 467 | for i in range(num_images): 468 | path = image_paths[i] 469 | try: 470 | print(f'Processing image {i+1}...') 471 | img = cv2.imread(path) 472 | H, W, _ = img.shape 473 | back = get_back(background_codes[i], background_paths, (W, H)) 474 | edited = do_artifact(img, back, 475 | text_shift_scale=args.s, 476 | text_shift_factor=args.r, 477 | line_slant_factor=args.k, 478 | line_move_factor=args.t, 479 | text_fade_factor=args.a 480 | ) 481 | 482 | save_path, ext = os.path.splitext(os.path.basename(path)) 483 | if args.output_format is not None: 484 | ext = '.'+args.output_format 485 | cv2.imwrite( 486 | os.path.join(args.out, '_edited'.join((save_path, ext))), 487 | edited 488 | ) 489 | except Exception as e: 490 | print(f"Could not process image '{path}'") 491 | print(e) 492 | --------------------------------------------------------------------------------