├── LICENSE ├── OpeningHoursPhoto.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcuserdata │ └── bryce.xcuserdatad │ └── xcschemes │ └── xcschememanagement.plist ├── OpeningHoursPhoto ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json ├── CameraView.swift ├── ContentView.swift ├── HoursRecognizer.swift ├── Info.plist ├── LevenshteinDistance.swift ├── OpeningHoursPhoto.entitlements ├── OpeningHoursPhoto.swift ├── OpeningHoursPhotoApp.swift └── Preview Content │ └── Preview Assets.xcassets │ └── Contents.json └── README.md /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /OpeningHoursPhoto.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 50; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 0285E350262118E600C97110 /* OpeningHoursPhotoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0285E34F262118E600C97110 /* OpeningHoursPhotoApp.swift */; }; 11 | 0285E352262118E600C97110 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0285E351262118E600C97110 /* ContentView.swift */; }; 12 | 0285E354262118F000C97110 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0285E353262118F000C97110 /* Assets.xcassets */; }; 13 | 0285E357262118F000C97110 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0285E356262118F000C97110 /* Preview Assets.xcassets */; }; 14 | 0285E3612621195C00C97110 /* OpeningHoursPhoto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0285E35E2621195C00C97110 /* OpeningHoursPhoto.swift */; }; 15 | 0285E3622621195C00C97110 /* HoursRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0285E35F2621195C00C97110 /* HoursRecognizer.swift */; }; 16 | 0285E3632621195C00C97110 /* CameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0285E3602621195C00C97110 /* CameraView.swift */; }; 17 | 02F0205A262F818E009D6CAF /* LevenshteinDistance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F02059262F818E009D6CAF /* LevenshteinDistance.swift */; }; 18 | /* End PBXBuildFile section */ 19 | 20 | /* Begin PBXFileReference section */ 21 | 0285E34C262118E600C97110 /* OpeningHoursPhoto.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpeningHoursPhoto.app; sourceTree = BUILT_PRODUCTS_DIR; }; 22 | 0285E34F262118E600C97110 /* OpeningHoursPhotoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpeningHoursPhotoApp.swift; sourceTree = ""; }; 23 | 0285E351262118E600C97110 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 24 | 0285E353262118F000C97110 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 25 | 0285E356262118F000C97110 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 26 | 0285E358262118F000C97110 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 27 | 0285E35E2621195C00C97110 /* OpeningHoursPhoto.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpeningHoursPhoto.swift; sourceTree = ""; }; 28 | 0285E35F2621195C00C97110 /* HoursRecognizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HoursRecognizer.swift; sourceTree = ""; }; 29 | 0285E3602621195C00C97110 /* CameraView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraView.swift; sourceTree = ""; }; 30 | 0285E3642623A4EA00C97110 /* OpeningHoursPhoto.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpeningHoursPhoto.entitlements; sourceTree = ""; }; 31 | 02F02059262F818E009D6CAF /* LevenshteinDistance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LevenshteinDistance.swift; sourceTree = ""; }; 32 | /* End PBXFileReference section */ 33 | 34 | /* Begin PBXFrameworksBuildPhase section */ 35 | 0285E349262118E600C97110 /* Frameworks */ = { 36 | isa = PBXFrameworksBuildPhase; 37 | buildActionMask = 2147483647; 38 | files = ( 39 | ); 40 | runOnlyForDeploymentPostprocessing = 0; 41 | }; 42 | /* End PBXFrameworksBuildPhase section */ 43 | 44 | /* Begin PBXGroup section */ 45 | 0285E343262118E600C97110 = { 46 | isa = PBXGroup; 47 | children = ( 48 | 0285E34E262118E600C97110 /* OpeningHoursPhoto */, 49 | 0285E34D262118E600C97110 /* Products */, 50 | ); 51 | sourceTree = ""; 52 | }; 53 | 0285E34D262118E600C97110 /* Products */ = { 54 | isa = PBXGroup; 55 | children = ( 56 | 0285E34C262118E600C97110 /* OpeningHoursPhoto.app */, 57 | ); 58 | name = Products; 59 | sourceTree = ""; 60 | }; 61 | 0285E34E262118E600C97110 /* OpeningHoursPhoto */ = { 62 | isa = PBXGroup; 63 | children = ( 64 | 0285E353262118F000C97110 /* Assets.xcassets */, 65 | 0285E3602621195C00C97110 /* CameraView.swift */, 66 | 0285E35E2621195C00C97110 /* OpeningHoursPhoto.swift */, 67 | 0285E351262118E600C97110 /* ContentView.swift */, 68 | 0285E35F2621195C00C97110 /* HoursRecognizer.swift */, 69 | 0285E358262118F000C97110 /* Info.plist */, 70 | 0285E3642623A4EA00C97110 /* OpeningHoursPhoto.entitlements */, 71 | 0285E34F262118E600C97110 /* OpeningHoursPhotoApp.swift */, 72 | 0285E355262118F000C97110 /* Preview Content */, 73 | 02F02059262F818E009D6CAF /* LevenshteinDistance.swift */, 74 | ); 75 | path = OpeningHoursPhoto; 76 | sourceTree = ""; 77 | }; 78 | 0285E355262118F000C97110 /* Preview Content */ = { 79 | isa = PBXGroup; 80 | children = ( 81 | 0285E356262118F000C97110 /* Preview Assets.xcassets */, 82 | ); 83 | path = "Preview Content"; 84 | sourceTree = ""; 85 | }; 86 | /* End PBXGroup section */ 87 | 88 | /* Begin PBXNativeTarget section */ 89 | 0285E34B262118E600C97110 /* OpeningHoursPhoto */ = { 90 | isa = PBXNativeTarget; 91 | buildConfigurationList = 0285E35B262118F000C97110 /* Build configuration list for PBXNativeTarget "OpeningHoursPhoto" */; 92 | buildPhases = ( 93 | 0285E348262118E600C97110 /* Sources */, 94 | 0285E349262118E600C97110 /* Frameworks */, 95 | 0285E34A262118E600C97110 /* Resources */, 96 | ); 97 | buildRules = ( 98 | ); 99 | dependencies = ( 100 | ); 101 | name = OpeningHoursPhoto; 102 | productName = OpeningHoursPhoto; 103 | productReference = 0285E34C262118E600C97110 /* OpeningHoursPhoto.app */; 104 | productType = "com.apple.product-type.application"; 105 | }; 106 | /* End PBXNativeTarget section */ 107 | 108 | /* Begin PBXProject section */ 109 | 0285E344262118E600C97110 /* Project object */ = { 110 | isa = PBXProject; 111 | attributes = { 112 | LastSwiftUpdateCheck = 1250; 113 | LastUpgradeCheck = 1250; 114 | TargetAttributes = { 115 | 0285E34B262118E600C97110 = { 116 | CreatedOnToolsVersion = 12.5; 117 | }; 118 | }; 119 | }; 120 | buildConfigurationList = 0285E347262118E600C97110 /* Build configuration list for PBXProject "OpeningHoursPhoto" */; 121 | compatibilityVersion = "Xcode 9.3"; 122 | developmentRegion = en; 123 | hasScannedForEncodings = 0; 124 | knownRegions = ( 125 | en, 126 | Base, 127 | ); 128 | mainGroup = 0285E343262118E600C97110; 129 | productRefGroup = 0285E34D262118E600C97110 /* Products */; 130 | projectDirPath = ""; 131 | projectRoot = ""; 132 | targets = ( 133 | 0285E34B262118E600C97110 /* OpeningHoursPhoto */, 134 | ); 135 | }; 136 | /* End PBXProject section */ 137 | 138 | /* Begin PBXResourcesBuildPhase section */ 139 | 0285E34A262118E600C97110 /* Resources */ = { 140 | isa = PBXResourcesBuildPhase; 141 | buildActionMask = 2147483647; 142 | files = ( 143 | 0285E357262118F000C97110 /* Preview Assets.xcassets in Resources */, 144 | 0285E354262118F000C97110 /* Assets.xcassets in Resources */, 145 | ); 146 | runOnlyForDeploymentPostprocessing = 0; 147 | }; 148 | /* End PBXResourcesBuildPhase section */ 149 | 150 | /* Begin PBXSourcesBuildPhase section */ 151 | 0285E348262118E600C97110 /* Sources */ = { 152 | isa = PBXSourcesBuildPhase; 153 | buildActionMask = 2147483647; 154 | files = ( 155 | 02F0205A262F818E009D6CAF /* LevenshteinDistance.swift in Sources */, 156 | 0285E352262118E600C97110 /* ContentView.swift in Sources */, 157 | 0285E3622621195C00C97110 /* HoursRecognizer.swift in Sources */, 158 | 0285E350262118E600C97110 /* OpeningHoursPhotoApp.swift in Sources */, 159 | 0285E3632621195C00C97110 /* CameraView.swift in Sources */, 160 | 0285E3612621195C00C97110 /* OpeningHoursPhoto.swift in Sources */, 161 | ); 162 | runOnlyForDeploymentPostprocessing = 0; 163 | }; 164 | /* End PBXSourcesBuildPhase section */ 165 | 166 | /* Begin XCBuildConfiguration section */ 167 | 0285E359262118F000C97110 /* Debug */ = { 168 | isa = XCBuildConfiguration; 169 | buildSettings = { 170 | ALWAYS_SEARCH_USER_PATHS = NO; 171 | CLANG_ANALYZER_NONNULL = YES; 172 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 173 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 174 | CLANG_CXX_LIBRARY = "libc++"; 175 | CLANG_ENABLE_MODULES = YES; 176 | CLANG_ENABLE_OBJC_ARC = YES; 177 | CLANG_ENABLE_OBJC_WEAK = YES; 178 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 179 | CLANG_WARN_BOOL_CONVERSION = YES; 180 | CLANG_WARN_COMMA = YES; 181 | CLANG_WARN_CONSTANT_CONVERSION = YES; 182 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 183 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 184 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 185 | CLANG_WARN_EMPTY_BODY = YES; 186 | CLANG_WARN_ENUM_CONVERSION = YES; 187 | CLANG_WARN_INFINITE_RECURSION = YES; 188 | CLANG_WARN_INT_CONVERSION = YES; 189 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 190 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 191 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 192 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 193 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 194 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 195 | CLANG_WARN_STRICT_PROTOTYPES = YES; 196 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 197 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 198 | CLANG_WARN_UNREACHABLE_CODE = YES; 199 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 200 | COPY_PHASE_STRIP = NO; 201 | DEBUG_INFORMATION_FORMAT = dwarf; 202 | ENABLE_STRICT_OBJC_MSGSEND = YES; 203 | ENABLE_TESTABILITY = YES; 204 | GCC_C_LANGUAGE_STANDARD = gnu11; 205 | GCC_DYNAMIC_NO_PIC = NO; 206 | GCC_NO_COMMON_BLOCKS = YES; 207 | GCC_OPTIMIZATION_LEVEL = 0; 208 | GCC_PREPROCESSOR_DEFINITIONS = ( 209 | "DEBUG=1", 210 | "$(inherited)", 211 | ); 212 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 213 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 214 | GCC_WARN_UNDECLARED_SELECTOR = YES; 215 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 216 | GCC_WARN_UNUSED_FUNCTION = YES; 217 | GCC_WARN_UNUSED_VARIABLE = YES; 218 | IPHONEOS_DEPLOYMENT_TARGET = 14.5; 219 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 220 | MTL_FAST_MATH = YES; 221 | ONLY_ACTIVE_ARCH = YES; 222 | SDKROOT = iphoneos; 223 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 224 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 225 | }; 226 | name = Debug; 227 | }; 228 | 0285E35A262118F000C97110 /* Release */ = { 229 | isa = XCBuildConfiguration; 230 | buildSettings = { 231 | ALWAYS_SEARCH_USER_PATHS = NO; 232 | CLANG_ANALYZER_NONNULL = YES; 233 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 234 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 235 | CLANG_CXX_LIBRARY = "libc++"; 236 | CLANG_ENABLE_MODULES = YES; 237 | CLANG_ENABLE_OBJC_ARC = YES; 238 | CLANG_ENABLE_OBJC_WEAK = YES; 239 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 240 | CLANG_WARN_BOOL_CONVERSION = YES; 241 | CLANG_WARN_COMMA = YES; 242 | CLANG_WARN_CONSTANT_CONVERSION = YES; 243 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 244 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 245 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 246 | CLANG_WARN_EMPTY_BODY = YES; 247 | CLANG_WARN_ENUM_CONVERSION = YES; 248 | CLANG_WARN_INFINITE_RECURSION = YES; 249 | CLANG_WARN_INT_CONVERSION = YES; 250 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 251 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 252 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 253 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 254 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 255 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 256 | CLANG_WARN_STRICT_PROTOTYPES = YES; 257 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 258 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 259 | CLANG_WARN_UNREACHABLE_CODE = YES; 260 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 261 | COPY_PHASE_STRIP = NO; 262 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 263 | ENABLE_NS_ASSERTIONS = NO; 264 | ENABLE_STRICT_OBJC_MSGSEND = YES; 265 | GCC_C_LANGUAGE_STANDARD = gnu11; 266 | GCC_NO_COMMON_BLOCKS = YES; 267 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 268 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 269 | GCC_WARN_UNDECLARED_SELECTOR = YES; 270 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 271 | GCC_WARN_UNUSED_FUNCTION = YES; 272 | GCC_WARN_UNUSED_VARIABLE = YES; 273 | IPHONEOS_DEPLOYMENT_TARGET = 14.5; 274 | MTL_ENABLE_DEBUG_INFO = NO; 275 | MTL_FAST_MATH = YES; 276 | SDKROOT = iphoneos; 277 | SWIFT_COMPILATION_MODE = wholemodule; 278 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 279 | VALIDATE_PRODUCT = YES; 280 | }; 281 | name = Release; 282 | }; 283 | 0285E35C262118F000C97110 /* Debug */ = { 284 | isa = XCBuildConfiguration; 285 | buildSettings = { 286 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 287 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 288 | CODE_SIGN_ENTITLEMENTS = OpeningHoursPhoto/OpeningHoursPhoto.entitlements; 289 | CODE_SIGN_STYLE = Automatic; 290 | DEVELOPMENT_ASSET_PATHS = "\"OpeningHoursPhoto/Preview Content\""; 291 | DEVELOPMENT_TEAM = MKTTC6734C; 292 | ENABLE_PREVIEWS = YES; 293 | INFOPLIST_FILE = OpeningHoursPhoto/Info.plist; 294 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 295 | LD_RUNPATH_SEARCH_PATHS = ( 296 | "$(inherited)", 297 | "@executable_path/Frameworks", 298 | ); 299 | PRODUCT_BUNDLE_IDENTIFIER = com.Bryceco.OpeningHoursPhoto; 300 | PRODUCT_NAME = "$(TARGET_NAME)"; 301 | SUPPORTS_MACCATALYST = YES; 302 | SWIFT_VERSION = 5.0; 303 | TARGETED_DEVICE_FAMILY = "1,2"; 304 | }; 305 | name = Debug; 306 | }; 307 | 0285E35D262118F000C97110 /* Release */ = { 308 | isa = XCBuildConfiguration; 309 | buildSettings = { 310 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 311 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 312 | CODE_SIGN_ENTITLEMENTS = OpeningHoursPhoto/OpeningHoursPhoto.entitlements; 313 | CODE_SIGN_STYLE = Automatic; 314 | DEVELOPMENT_ASSET_PATHS = "\"OpeningHoursPhoto/Preview Content\""; 315 | DEVELOPMENT_TEAM = MKTTC6734C; 316 | ENABLE_PREVIEWS = YES; 317 | INFOPLIST_FILE = OpeningHoursPhoto/Info.plist; 318 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 319 | LD_RUNPATH_SEARCH_PATHS = ( 320 | "$(inherited)", 321 | "@executable_path/Frameworks", 322 | ); 323 | PRODUCT_BUNDLE_IDENTIFIER = com.Bryceco.OpeningHoursPhoto; 324 | PRODUCT_NAME = "$(TARGET_NAME)"; 325 | SUPPORTS_MACCATALYST = YES; 326 | SWIFT_VERSION = 5.0; 327 | TARGETED_DEVICE_FAMILY = "1,2"; 328 | }; 329 | name = Release; 330 | }; 331 | /* End XCBuildConfiguration section */ 332 | 333 | /* Begin XCConfigurationList section */ 334 | 0285E347262118E600C97110 /* Build configuration list for PBXProject "OpeningHoursPhoto" */ = { 335 | isa = XCConfigurationList; 336 | buildConfigurations = ( 337 | 0285E359262118F000C97110 /* Debug */, 338 | 0285E35A262118F000C97110 /* Release */, 339 | ); 340 | defaultConfigurationIsVisible = 0; 341 | defaultConfigurationName = Release; 342 | }; 343 | 0285E35B262118F000C97110 /* Build configuration list for PBXNativeTarget "OpeningHoursPhoto" */ = { 344 | isa = XCConfigurationList; 345 | buildConfigurations = ( 346 | 0285E35C262118F000C97110 /* Debug */, 347 | 0285E35D262118F000C97110 /* Release */, 348 | ); 349 | defaultConfigurationIsVisible = 0; 350 | defaultConfigurationName = Release; 351 | }; 352 | /* End XCConfigurationList section */ 353 | }; 354 | rootObject = 0285E344262118E600C97110 /* Project object */; 355 | } 356 | -------------------------------------------------------------------------------- /OpeningHoursPhoto.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /OpeningHoursPhoto.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /OpeningHoursPhoto.xcodeproj/xcuserdata/bryce.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | OpeningHoursPhoto.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /OpeningHoursPhoto/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /OpeningHoursPhoto/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /OpeningHoursPhoto/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /OpeningHoursPhoto/CameraView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CameraViewController.swift 3 | // 4 | // Created by Bryce Cogswell on 4/8/21. 5 | // 6 | 7 | import UIKit 8 | import AVFoundation 9 | import Vision 10 | 11 | class CameraView: UIView, AVCapturePhotoCaptureDelegate, AVCaptureVideoDataOutputSampleBufferDelegate { 12 | 13 | private var captureSession: AVCaptureSession? = nil 14 | private var stillImageOutput: AVCapturePhotoOutput? = nil 15 | private var videoOutput: AVCaptureVideoDataOutput = AVCaptureVideoDataOutput() 16 | private let videoOutputQueue = DispatchQueue(label: "com.gomaposm.openinghours.VideoOutputQueue") 17 | 18 | var photoCallback: ((CGImage)->(Void))? = nil 19 | var observationsCallback: (([VNRecognizedTextObservation], CameraView)->(Void))? = nil 20 | var shouldRecordCallback: (()->(Bool))? = nil 21 | var languages: [String] = [] 22 | 23 | override func layoutSubviews() { 24 | super.layoutSubviews() 25 | for layer in self.layer.sublayers ?? [] { 26 | layer.frame = CGRect(origin: layer.bounds.origin, size: self.layer.frame.size) 27 | } 28 | } 29 | 30 | override init(frame: CGRect) { 31 | 32 | super.init(frame: frame) 33 | 34 | // session 35 | let captureSession = AVCaptureSession() 36 | self.captureSession = captureSession 37 | captureSession.sessionPreset = AVCaptureSession.Preset.high 38 | 39 | // input source 40 | guard let backCamera = AVCaptureDevice.default(for: AVMediaType.video), 41 | let input = try? AVCaptureDeviceInput(device: backCamera) 42 | else { return } 43 | if captureSession.canAddInput(input) { 44 | captureSession.addInput(input) 45 | } 46 | 47 | // video output 48 | videoOutput.alwaysDiscardsLateVideoFrames = true 49 | videoOutput.setSampleBufferDelegate(self, queue: videoOutputQueue) 50 | if captureSession.canAddOutput(videoOutput) { 51 | captureSession.addOutput(videoOutput) 52 | videoOutput.connection(with: AVMediaType.video)?.preferredVideoStabilizationMode = .off 53 | } 54 | 55 | // photo output 56 | stillImageOutput = AVCapturePhotoOutput() 57 | if captureSession.canAddOutput(stillImageOutput!) { 58 | captureSession.addOutput(stillImageOutput!) 59 | } 60 | 61 | // preview layer 62 | let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession) 63 | previewLayer.videoGravity = AVLayerVideoGravity.resizeAspect 64 | previewLayer.connection?.videoOrientation = AVCaptureVideoOrientation.portrait 65 | self.layer.addSublayer(previewLayer) 66 | 67 | captureSession.startRunning() 68 | } 69 | 70 | public func startRunning() { 71 | captureSession?.startRunning() 72 | } 73 | public func stopRunning() { 74 | captureSession?.stopRunning() 75 | } 76 | 77 | required init?(coder: NSCoder) { 78 | fatalError("init(coder:) has not been implemented") 79 | } 80 | 81 | internal func photoOutput(_ output: AVCapturePhotoOutput, 82 | didFinishProcessingPhoto photo: AVCapturePhoto, 83 | error: Error?) 84 | { 85 | let cgImage = photo.cgImageRepresentation() 86 | if let cgImage = cgImage?.takeUnretainedValue() { 87 | #if true 88 | self.photoCallback?( cgImage ) 89 | #else 90 | let orientation = photo.metadata[kCGImagePropertyOrientation as String] as! NSNumber 91 | let uiOrientation = UIImage.Orientation(rawValue: orientation.intValue)! 92 | let image = UIImage(cgImage: cgImage, scale: 1, orientation: uiOrientation) 93 | self.photoCallback?( image ) 94 | #endif 95 | } 96 | } 97 | 98 | @IBAction func takePhoto(sender: AnyObject?) { 99 | if let videoConnection = stillImageOutput!.connection(with: AVMediaType.video) { 100 | videoConnection.videoOrientation = AVCaptureVideoOrientation.portrait 101 | stillImageOutput?.capturePhoto(with: AVCapturePhotoSettings(), delegate: self) 102 | } 103 | } 104 | 105 | private var boxLayers = [CALayer]() 106 | private var newBoxes = [(UIColor,[CGRect])]() 107 | public func addBoxes( boxes: [CGRect], color: UIColor ) { 108 | newBoxes.append( (color,boxes) ) 109 | } 110 | private func displayBoxes() { 111 | DispatchQueue.main.async { 112 | // remove current boxes 113 | for layer in self.boxLayers { 114 | layer.removeFromSuperlayer() 115 | } 116 | self.boxLayers.removeAll() 117 | 118 | // add new boxes 119 | let rotationTransform = CGAffineTransform(translationX: 0, y: 1).rotated(by: -CGFloat.pi / 2) 120 | let bottomToTopTransform = CGAffineTransform(scaleX: 1, y: -1).translatedBy(x: 0, y: -1) 121 | let visionToAVFTransform = CGAffineTransform.identity.concatenating(bottomToTopTransform).concatenating(rotationTransform) 122 | 123 | guard let previewLayer = self.layer.sublayers?.first as? AVCaptureVideoPreviewLayer else { return } 124 | for (color,boxes) in self.newBoxes { 125 | for box in boxes { 126 | let rect = previewLayer.layerRectConverted(fromMetadataOutputRect: box.applying(visionToAVFTransform)) 127 | let layer = CAShapeLayer() 128 | layer.opacity = 1.0 129 | layer.borderColor = color.cgColor 130 | layer.borderWidth = 2 131 | layer.frame = rect 132 | self.boxLayers.append(layer) 133 | previewLayer.addSublayer(layer) 134 | } 135 | } 136 | self.newBoxes.removeAll() 137 | } 138 | } 139 | 140 | private func addBoxes(forObservations results: [VNRecognizedTextObservation]) { 141 | var boxes = [CGRect]() 142 | for result in results { 143 | if let candidate = result.topCandidates(1).first, 144 | let box = try? candidate.boundingBox(for: candidate.string.startIndex.. Float { 34 | let overlap = max(0.0, min(self.maxX,rect.maxX) - max(self.minX,rect.minX)) 35 | * max(0.0, min(self.maxY,rect.maxY) - max(self.minY,rect.minY)) 36 | let size1 = self.width * self.height 37 | let size2 = rect.width * rect.height 38 | return Float(overlap / (size1+size2-overlap)) 39 | } 40 | } 41 | 42 | // return a list where all items are removed except the two with highest confidence (preserving their order) 43 | extension Array { 44 | fileprivate func bestTwo(_ lessThan: (_ lhs: Self.Element, _ rhs: Self.Element) -> Bool) -> [Self.Element] { 45 | if self.count <= 2 { 46 | return self 47 | } 48 | var b0 = 0 49 | var b1 = 1 50 | for i in 2..)->CGRect) 66 | fileprivate typealias StringRect = (string:String, rect:CGRect) 67 | 68 | // A version of Scanner that returns a rect for each string 69 | fileprivate class RectScanner { 70 | 71 | let substring: Substring 72 | let scanner: Scanner 73 | let rectf:(Range)->CGRect 74 | 75 | static private let allLetters = CharacterSet.uppercaseLetters.union(CharacterSet.lowercaseLetters) 76 | 77 | init(substring: Substring, rectf:@escaping (Range)->CGRect) { 78 | self.substring = substring 79 | self.scanner = Scanner(string: String(substring)) 80 | self.scanner.caseSensitive = false 81 | self.scanner.charactersToBeSkipped = nil 82 | self.rectf = rectf 83 | } 84 | 85 | var currentIndex: String.Index { 86 | get { scanner.currentIndex } 87 | set { scanner.currentIndex = newValue } 88 | } 89 | 90 | var string: String { return scanner.string } 91 | 92 | var isAtEnd: Bool { return scanner.isAtEnd } 93 | 94 | private func result(_ sub:Substring) -> StringRect { 95 | let d1 = sub.distance(from: sub.base.startIndex, to: sub.startIndex ) 96 | let d2 = sub.distance(from: sub.base.startIndex, to: sub.endIndex ) 97 | let p1 = substring.index(substring.startIndex, offsetBy: d1) 98 | let p2 = substring.index(substring.startIndex, offsetBy: d2) 99 | let rect = rectf(p1.. StringRect { 104 | let last = scanner.string.index(before: scanner.string.endIndex) 105 | return result(scanner.string[last.. StringRect? { 109 | let index = scanner.currentIndex 110 | if let _ = scanner.scanString(string) { 111 | return result(scanner.string[index.. StringRect? { 117 | let index = scanner.currentIndex 118 | if let _ = scanner.scanCharacters(from: CharacterSet.whitespacesAndNewlines) { 119 | return result(scanner.string[index.. StringRect? { 125 | let index = scanner.currentIndex 126 | if index == scanner.string.endIndex { 127 | return nil 128 | } 129 | if let _ = scanner.scanCharacters(from: RectScanner.allLetters) ?? 130 | scanner.scanCharacters(from: CharacterSet.decimalDigits) 131 | { 132 | return result(scanner.string[index.. StringRect? { 140 | let index = scanner.currentIndex 141 | if let _ = scanner.scanInt() { 142 | return result(scanner.string[index.. StringRect? { 148 | return scanAnyWord([word]) 149 | } 150 | 151 | func scanAnyWord(_ words: [String]) -> StringRect? { 152 | let index = self.currentIndex 153 | if let scan = scanner.scanCharacters(from: RectScanner.allLetters)?.lowercased() { 154 | // we match if the scanned word is a 2-3 letter prefix of the first word in the list 155 | if (2...3).contains(scan.count) && words.first!.hasPrefix(scan) { 156 | return result(scanner.string[index.. 4 { 161 | if LevenshteinDistance( word, scan) <= scan.count/4 { 162 | return result(scanner.string[index.. String { 176 | return String(scanner.string[scanner.currentIndex...]) 177 | } 178 | } 179 | 180 | // A version of Scanner that accepts an array of substrings and can extract rectangles for them 181 | fileprivate class MultiScanner { 182 | 183 | let strings: [SubstringRectf] 184 | let scanners: [RectScanner] 185 | var scannerIndex: Int 186 | 187 | init(strings: [SubstringRectf]) { 188 | self.strings = strings 189 | self.scanners = strings.map { RectScanner(substring: $0.string, rectf:$0.rectf) } 190 | self.scannerIndex = 0 191 | } 192 | 193 | var currentIndex: (scanner:Int, index:String.Index) { 194 | get { (scannerIndex, scanners[scannerIndex].currentIndex) } 195 | set { scannerIndex = newValue.0 196 | scanners[scannerIndex].currentIndex = newValue.1 197 | for scan in scanners[(scannerIndex+1)...] { 198 | scan.currentIndex = scan.string.startIndex 199 | } 200 | } 201 | } 202 | 203 | var scanner:RectScanner { 204 | get { 205 | while scanners[scannerIndex].isAtEnd && scannerIndex+1 < scanners.count { 206 | scannerIndex += 1 207 | } 208 | return scanners[scannerIndex] 209 | } 210 | } 211 | 212 | var isAtEnd: Bool { return scanner.isAtEnd } 213 | 214 | func scanString(_ string: String) -> StringRect? { 215 | // we need to fudge an implied space at the break between two observations: 216 | if string == " " && scannerIndex > 0 && scanner.currentIndex == scanner.string.startIndex { 217 | // return rect for previous character 218 | let rect = scanners[scannerIndex-1].lastChar().rect 219 | return (" ",rect) 220 | } 221 | return scanner.scanString(string) 222 | } 223 | 224 | func scanWhitespace() -> StringRect? { 225 | var sub = scanner.scanWhitespace() 226 | // repeat in case we need to switch to next scanner 227 | if sub != nil { 228 | while let s = scanner.scanWhitespace() { 229 | sub = (sub!.string + s.string, sub!.rect.union(s.rect)) 230 | } 231 | } 232 | return sub 233 | } 234 | 235 | func scanUpToWhitespace() -> StringRect? { 236 | return scanner.scanUpToWhitespace() 237 | } 238 | 239 | func scanInt() -> StringRect? { 240 | return scanner.scanInt() 241 | } 242 | 243 | func scanWord(_ word: String) -> StringRect? { 244 | return scanner.scanWord( word ) 245 | } 246 | 247 | func scanAnyWord(_ words: [String]) -> StringRect? { 248 | return scanner.scanAnyWord(words) 249 | } 250 | 251 | func remainder() -> String { 252 | return scanners[scannerIndex...].map({$0.remainder()}).joined(separator: " ") 253 | } 254 | } 255 | 256 | fileprivate enum Day: Int, Strideable, CaseIterable { 257 | case Mo, Tu, We, Th, Fr, Sa, Su 258 | 259 | func distance(to other: Day) -> Int { 260 | return (other.rawValue - self.rawValue + 7) % 7 261 | } 262 | 263 | func advanced(by n: Int) -> Day { 264 | return Day(rawValue: (self.rawValue + n + 7) % 7)! 265 | } 266 | 267 | private static let english = [ Day.Mo: ["monday"], 268 | Day.Tu: ["tuesday"], 269 | Day.We: ["wednesday"], 270 | Day.Th: ["thursday", "thur"], 271 | Day.Fr: ["friday"], 272 | Day.Sa: ["saturday"], 273 | Day.Su: ["sunday"] 274 | ] 275 | private static let french = [ Day.Mo: ["lundi"], 276 | Day.Tu: ["mardi"], 277 | Day.We: ["mercredi", "mercr"], 278 | Day.Th: ["jeudi"], 279 | Day.Fr: ["vendredi", "vendr"], 280 | Day.Sa: ["samedi"], 281 | Day.Su: ["dimanche"] 282 | ] 283 | private static let german = [ Day.Mo: ["montag"], 284 | Day.Tu: ["dienstag"], 285 | Day.We: ["mittwoch"], 286 | Day.Th: ["donnerstag"], 287 | Day.Fr: ["freitag"], 288 | Day.Sa: ["samstag"], 289 | Day.Su: ["sonntag"] 290 | ] 291 | private static let italian = [ Day.Mo: ["lunedì"], 292 | Day.Tu: ["martedì"], 293 | Day.We: ["mercoledì"], 294 | Day.Th: ["giovedì"], 295 | Day.Fr: ["venerdì"], 296 | Day.Sa: ["sabato"], 297 | Day.Su: ["domenica"] 298 | ] 299 | 300 | static func scan(scanner:MultiScanner, language:HoursRecognizer.Language) -> (day:Self, rect:CGRect, confidence:Float)? { 301 | let dict = { () -> [Day:[String]] in 302 | switch language { 303 | case .en: return english 304 | case .fr: return french 305 | case .de: return german 306 | case .it: return italian 307 | } 308 | }() 309 | for (day,strings) in dict { 310 | if let s = scanner.scanAnyWord(strings) { 311 | return (day,s.rect,Float(s.string.count)) 312 | } 313 | } 314 | return nil 315 | } 316 | 317 | fileprivate static func rangeForSet( _ set: Set ) -> [DayRange] { 318 | var dayList = [DayRange]() 319 | var dayRange: DayRange? = nil 320 | for d in Day.allCases { 321 | if set.contains(d) { 322 | if let range = dayRange { 323 | dayRange = DayRange(start: range.start, end: d) 324 | } else { 325 | dayRange = DayRange(start: d, end: d) 326 | } 327 | } else { 328 | if let range = dayRange { 329 | dayList.append( range ) 330 | } 331 | dayRange = nil 332 | } 333 | } 334 | if let range = dayRange { 335 | dayList.append( range ) 336 | } 337 | return dayList 338 | } 339 | } 340 | 341 | fileprivate struct Time: Comparable, Hashable { 342 | 343 | let minutes: Int 344 | 345 | var text: String { return String(format: "%02d:%02d", minutes/60, minutes%60) } 346 | 347 | init(hour: Int, minute:Int) { 348 | self.minutes = hour*60 + minute 349 | } 350 | 351 | static func < (lhs: Time, rhs: Time) -> Bool { 352 | return lhs.minutes < rhs.minutes 353 | } 354 | 355 | static func scan(scanner: MultiScanner, language:HoursRecognizer.Language) -> (time:Self, rect:CGRect, confidence:Float)? { 356 | let seperators: [String] = { () -> [String] in 357 | switch language { 358 | case .fr: 359 | return [":", ".", " ", "h"] 360 | case .de, .en, .it: 361 | return [":", ".", " "] 362 | }}() 363 | 364 | let index = scanner.currentIndex 365 | guard let hour = scanner.scanInt() else { return nil } 366 | if let iHour = Int(hour.string), 367 | iHour >= 0 && iHour <= 24 368 | { 369 | let index2 = scanner.currentIndex 370 | if seperators.first(where: {scanner.scanString($0) != nil}) != nil, 371 | let minute = scanner.scanInt(), 372 | minute.string.count == 2, 373 | minute.string >= "00" && minute.string < "60" 374 | { 375 | _ = scanner.scanWhitespace() 376 | if let am = scanner.scanString("AM") { 377 | return (Time(hour: iHour%12, minute: Int(minute.string)!), 378 | hour.rect.union(am.rect), 379 | 8.0) 380 | } 381 | if let pm = scanner.scanString("PM") { 382 | return (Time(hour: (iHour%12)+12, minute: Int(minute.string)!), 383 | hour.rect.union(pm.rect), 384 | 8.0) 385 | } 386 | return (Time(hour: iHour, minute: Int(minute.string)!), 387 | hour.rect.union(minute.rect), 388 | 6.0) 389 | } 390 | scanner.currentIndex = index2 391 | 392 | _ = scanner.scanWhitespace() 393 | if let am = scanner.scanString("AM") { 394 | return (Time(hour: iHour%12, minute: 0), 395 | hour.rect.union(am.rect), 396 | 4.0) 397 | } 398 | if let pm = scanner.scanString("PM") { 399 | return (Time(hour: (iHour%12)+12, minute: 0), 400 | hour.rect.union(pm.rect), 401 | 4.0) 402 | } 403 | return (Time(hour: iHour, minute: 0), 404 | hour.rect, 405 | 1.0) 406 | } 407 | scanner.currentIndex = index 408 | return nil 409 | } 410 | } 411 | 412 | fileprivate struct Dash { 413 | static func scan(scanner: MultiScanner, language: HoursRecognizer.Language) -> (Self,CGRect,Float)? { 414 | let through = { () -> String in 415 | switch language { 416 | case .en: return "to" 417 | case .de: return "bis" 418 | case .fr: return "à" 419 | case .it: return "alle" 420 | }}() 421 | 422 | if let s = scanner.scanString("-") ?? scanner.scanWord(through) { 423 | return (Dash(), s.rect, Float(s.string.count)) 424 | } 425 | return nil 426 | } 427 | } 428 | 429 | fileprivate struct DayRange : Hashable { let start:Day; let end:Day } 430 | fileprivate struct TimeRange : Hashable { let start:Time; let end:Time } 431 | 432 | fileprivate typealias SubstringRectConfidence = (substring:Substring, rect:CGRect, rectf:(Range)->CGRect, confidence:Float) 433 | fileprivate typealias TokenRectConfidence = (token:Token, rect:CGRect, confidence:Float) 434 | 435 | fileprivate enum Token : Equatable { 436 | case time(Time) 437 | case day(Day) 438 | case dash(Dash) 439 | case endOfText 440 | 441 | static func == (lhs: Token, rhs: Token) -> Bool { 442 | return "\(lhs)" == "\(rhs)" 443 | } 444 | 445 | func day() -> Day? { 446 | switch self { 447 | case let .day(day): return day 448 | default: return nil 449 | } 450 | } 451 | func time() -> Time? { 452 | switch self { 453 | case let .time(time): return time 454 | default: return nil 455 | } 456 | } 457 | func dash() -> Dash? { 458 | switch self { 459 | case let .dash(dash): return dash 460 | default: return nil 461 | } 462 | } 463 | func isDay() -> Bool { return day() != nil } 464 | func isTime() -> Bool { return time() != nil } 465 | func isDash() -> Bool { return dash() != nil } 466 | 467 | static func scan(scanner: MultiScanner, language: HoursRecognizer.Language) -> TokenRectConfidence? { 468 | if let (day,rect,confidence) = Day.scan(scanner: scanner, language: language) { 469 | return (.day(day),rect,confidence) 470 | } 471 | if let (time,rect,confidence) = Time.scan(scanner: scanner, language: language) { 472 | return (.time(time),rect,confidence) 473 | } 474 | if let (dash,rect,confidence) = Dash.scan(scanner: scanner, language: language) { 475 | return (.dash(dash),rect,confidence) 476 | } 477 | return nil 478 | } 479 | } 480 | 481 | public class HoursRecognizer: ObservableObject { 482 | 483 | private var resultHistory = [String:Int]() 484 | @Published private(set) var finished = false { 485 | willSet { 486 | objectWillChange.send() 487 | } 488 | } 489 | 490 | static var lastLanguageSelected = Language.de 491 | @Published public var language: Language = lastLanguageSelected { 492 | willSet { 493 | HoursRecognizer.lastLanguageSelected = newValue 494 | } 495 | } 496 | 497 | @Published var text = "" { 498 | willSet { 499 | objectWillChange.send() 500 | } 501 | } 502 | 503 | init() { 504 | } 505 | 506 | public enum Language: String, CaseIterable, Identifiable { 507 | // these must be ISO codes 508 | case en = "en" 509 | case de = "de" 510 | case fr = "fr" 511 | case it = "it" 512 | 513 | public var id: String { self.rawValue } 514 | public var isoCode: String { "\(self)" } 515 | public var name: String { Locale(identifier: self.rawValue).localizedString(forIdentifier:self.rawValue) ?? "" } 516 | } 517 | 518 | public func restart() { 519 | self.text = "" 520 | self.resultHistory.removeAll() 521 | self.finished = false 522 | } 523 | 524 | private class func tokensForString(_ strings: [SubstringRectConfidence], language: Language) -> [TokenRectConfidence] { 525 | var list = [TokenRectConfidence]() 526 | 527 | let scanner = MultiScanner(strings: strings.map { return ($0.substring, $0.rectf)} ) 528 | _ = scanner.scanWhitespace() 529 | while !scanner.isAtEnd { 530 | if let token = Token.scan(scanner: scanner, language: language) { 531 | list.append( token ) 532 | } else { 533 | // skip to next token 534 | _ = scanner.scanUpToWhitespace() 535 | } 536 | _ = scanner.scanWhitespace() 537 | } 538 | return list 539 | } 540 | 541 | // takes an array of image observations and returns blocks of text along with their locations 542 | private class func stringsForImage(observations: [VNRecognizedTextObservation], transform:CGAffineTransform) -> [SubstringRectConfidence] { 543 | var wordList = [SubstringRectConfidence]() 544 | for observation in observations { 545 | guard let candidate = observation.topCandidates(1).first else { continue } 546 | // Each observation can contain text in disconnected parts of the screen, 547 | // so we tokenize the string and extract the screen location of each token 548 | let rectf:(Range)->CGRect = { 549 | let rect = try! candidate.boundingBox(for: $0)!.boundingBox 550 | let rect2 = rect.applying(transform) 551 | return rect2 552 | } 553 | let words = candidate.string.split(separator: " ") 554 | let words2 = words.map({ word -> SubstringRectConfidence in 555 | // Previous call returns tokens with substrings, which we can pass to candidate to get the rect 556 | let rect = rectf( word.startIndex ..< word.endIndex ) 557 | return (word, rect, rectf, candidate.confidence) 558 | }) 559 | wordList += words2 560 | } 561 | return wordList 562 | } 563 | 564 | // split observed text text blocks into lines of text, sorted left-to-right and top-to-bottom 565 | private class func getStringLines( _ allStrings: [SubstringRectConfidence] ) -> [[SubstringRectConfidence]] { 566 | var lines = [[SubstringRectConfidence]]() 567 | 568 | var list = allStrings 569 | 570 | while !list.isEmpty { 571 | 572 | // get highest confidence string 573 | let bestIndex = list.indices.max(by: {list[$0].confidence < list[$1].confidence})! 574 | let best = list[ bestIndex ] 575 | list.remove(at: bestIndex) 576 | var lineStrings = [best] 577 | 578 | // find tokens to left 579 | var prev = best 580 | while true { 581 | let strings = list.indices.filter({ list[$0].rect.maxX <= prev.rect.minX && (prev.rect.minY...prev.rect.maxY).contains( list[$0].rect.midY )}) 582 | if strings.isEmpty { break } 583 | let closest = strings.min(by: {prev.rect.minX - list[$0].rect.maxX < prev.rect.minX - list[$1].rect.maxX})! 584 | prev = list[closest] 585 | lineStrings.insert( prev, at: 0) 586 | list.remove(at: closest) 587 | } 588 | 589 | // find tokens to right 590 | prev = best 591 | while true { 592 | let strings = list.indices.filter({ list[$0].rect.minX >= prev.rect.maxX && (prev.rect.minY...prev.rect.maxY).contains( list[$0].rect.midY )}) 593 | if strings.isEmpty { break } 594 | let closest = strings.min(by: {list[$0].rect.minX - prev.rect.maxX < list[$1].rect.minX - prev.rect.maxX})! 595 | prev = list[closest] 596 | lineStrings.append( prev ) 597 | list.remove(at: closest) 598 | } 599 | 600 | // save the line of strings 601 | lines.append( lineStrings ) 602 | } 603 | 604 | // sort lines top-to-bottom 605 | lines.sort(by: {$0.first!.rect.minY < $1.first!.rect.minY} ) 606 | 607 | return lines 608 | } 609 | 610 | // convert lines of strings to lines of tokens 611 | private class func tokenLinesForStringLines( _ stringLines: [[SubstringRectConfidence]], language: Language) -> [[TokenRectConfidence]] { 612 | let tokenLines = stringLines.compactMap { line -> [TokenRectConfidence]? in 613 | let tokens = HoursRecognizer.tokensForString( line, language: language ) 614 | return tokens.isEmpty ? nil : tokens 615 | } 616 | return tokenLines 617 | } 618 | 619 | // split the lines so each sequence of days or times is in its own group 620 | private class func homogeneousSequencesForTokenLines( _ tokenLines: [[TokenRectConfidence]]) -> [[TokenRectConfidence]] { 621 | var tokenSets = [[TokenRectConfidence]]() 622 | for line in tokenLines { 623 | guard let first = line.indices.first(where: {!line[$0].token.isDash()}) else { continue } 624 | tokenSets.append([line[first]] ) 625 | var prevDash: TokenRectConfidence? = nil 626 | 627 | for token in line[(first+1)...] { 628 | if token.token.isDash() { 629 | prevDash = token 630 | } else if token.token.isDay() == tokenSets.last?.first?.token.isDay() || 631 | token.token.isTime() == tokenSets.last?.first?.token.isTime() 632 | { 633 | if let dash = prevDash { 634 | tokenSets[tokenSets.count-1].append(dash) 635 | prevDash = nil 636 | } 637 | tokenSets[tokenSets.count-1].append(token) 638 | } else { 639 | prevDash = nil 640 | tokenSets.append([token]) 641 | } 642 | } 643 | tokenSets.append([]) 644 | } 645 | tokenSets.removeAll(where: { $0.isEmpty }) 646 | 647 | return tokenSets 648 | } 649 | 650 | 651 | // if a sequence has multiple days then take only the best 2 652 | private class func GoodDaysForTokenSequences( _ tokenSet: [TokenRectConfidence]) -> [TokenRectConfidence]? { 653 | // return tokenSets.map( { return $0.first!.token.isDay() ? $0.bestTwo( {$0.confidence < $1.confidence} ) : $0 }) 654 | return tokenSet 655 | } 656 | 657 | // if a sequence has multiple times then take only the best even number 658 | private class func GoodTimesForTokenSequences( _ tokenSet: [TokenRectConfidence]) -> [TokenRectConfidence]? { 659 | var list = tokenSet 660 | var pairs = [(TokenRectConfidence,TokenRectConfidence)]() 661 | 662 | // pull out dash-seperated pairs 663 | while let dash = list.indices.first(where: {list[$0].token.isDash()}) { 664 | if dash > 1 && dash+1 < list.count { 665 | var priors = Array(list[0.. 0 ? pairs.flatMap({ [$0.0,$0.1] }) : nil 693 | } 694 | 695 | // convert lists of tokens to a list of day/time ranges 696 | private class func hoursForTokens(_ tokenLines: [[TokenRectConfidence]]) -> [([DayRange],[TimeRange])] { 697 | var days = [Day]() 698 | var times = [Time]() 699 | var result = [([DayRange],[TimeRange])]() 700 | 701 | for line in tokenLines + [[(.endOfText,CGRect(),0.0)]] { 702 | // each line should be 1 or more days, then 2 or more hours 703 | for token in line { 704 | switch token.token { 705 | case .day, .endOfText: 706 | if times.count >= 2 { 707 | // output preceding days/times 708 | var dayRange = [DayRange]() 709 | if days.count > 0 { 710 | if days.count == 2 { 711 | // treat as a range of days 712 | dayRange = [DayRange(start: days[0], end: days[1])] 713 | } else { 714 | // treat as a list of days 715 | dayRange = days.map({DayRange(start: $0, end: $0)}) 716 | } 717 | } 718 | 719 | let timeRange = stride(from: 0, to: times.count, by: 2).map({ TimeRange(start: times[$0], end: times[$0+1]) }) 720 | 721 | result.append( (dayRange, timeRange) ) 722 | } 723 | if times.count > 0 { 724 | times = [] 725 | days = [] 726 | } 727 | if let day = token.token.day() { 728 | days.append( day ) 729 | } 730 | 731 | case .time: 732 | times.append(token.token.time()!) 733 | break 734 | case .dash: 735 | break 736 | } 737 | } 738 | } 739 | return result 740 | } 741 | 742 | private class func coalesceDays(_ dayTimeRanges: [([DayRange],[TimeRange])] ) -> [([DayRange],[TimeRange])] { 743 | var dict = [[TimeRange] : Set]() 744 | for dayTime in dayTimeRanges { 745 | var daySet = Set( dayTime.0.flatMap({ stride(from: $0.start, through: $0.end, by: 1) })) 746 | if let set = dict[ dayTime.1 ] { 747 | daySet = daySet.union(set) 748 | } 749 | dict[ dayTime.1 ] = daySet 750 | } 751 | 752 | var list = dict.map({ (times,days) -> ([DayRange],[TimeRange]) in 753 | let dayList = Day.rangeForSet(days) 754 | return (dayList,times) 755 | }) 756 | list.sort(by: {($0.0.first?.start.rawValue ?? -1) < ($1.0.first?.start.rawValue ?? -1)}) 757 | return list 758 | } 759 | 760 | // convert lists of tokens to the final string 761 | private class func hoursStringForHours(_ dayTimeRanges: [([DayRange],[TimeRange])] ) -> String { 762 | return dayTimeRanges.map { (days,times) in 763 | var result = "" 764 | if !days.isEmpty { 765 | result += days.map({ $0.start == $0.end ? "\($0.start)" : "\($0.start)-\($0.end)"}) 766 | .joined(separator: ",") 767 | result += " " 768 | } 769 | result += times.map({"\($0.start)-\($0.end)"}) 770 | .joined(separator: ",") 771 | return result 772 | }.joined(separator: ", ") 773 | } 774 | 775 | private func updateWithObservations(observations: [VNRecognizedTextObservation], 776 | transform: CGAffineTransform, 777 | camera: CameraView?) 778 | { 779 | if finished { 780 | return 781 | } 782 | 783 | #if false 784 | let raw = observations.compactMap { $0.topCandidates(1).first?.string }.joined(separator: " ") 785 | Swift.print("\"\(raw)\"") 786 | #endif 787 | 788 | // get strings and locations 789 | let strings = HoursRecognizer.stringsForImage(observations: observations, transform: transform) 790 | 791 | #if false 792 | print("") 793 | print("strings:") 794 | for s in strings { 795 | print("\(s.substring): \(s.rect)") 796 | } 797 | #endif 798 | 799 | // split into lines of text 800 | let stringLines = HoursRecognizer.getStringLines( strings ) 801 | 802 | #if true 803 | print("") 804 | print("string lines:") 805 | for line in stringLines { 806 | let s1 = line.map({$0.substring}).joined(separator: " ") 807 | let s2 = line.map({"\($0.confidence)"}).joined(separator: " ") 808 | print("\(s1): \(s2)") 809 | } 810 | #endif 811 | 812 | // convert strings to tokens 813 | var tokenSets = HoursRecognizer.tokenLinesForStringLines( stringLines, language: self.language ) 814 | 815 | #if true 816 | print("") 817 | print("token lines:") 818 | for s in tokenSets { 819 | let s1 = s.map({ "\($0.token)"}).joined(separator: " ") 820 | let s2 = s.map({ "\($0.confidence)"}).joined(separator: " ") 821 | print("\(s1): \(s2)") 822 | } 823 | #endif 824 | 825 | tokenSets = HoursRecognizer.homogeneousSequencesForTokenLines( tokenSets ) 826 | 827 | // get homogeneous day/time sets 828 | tokenSets = tokenSets.compactMap { 829 | if $0.first!.token.isDay() { 830 | return HoursRecognizer.GoodDaysForTokenSequences( $0 ) 831 | } else { 832 | return HoursRecognizer.GoodTimesForTokenSequences( $0 ) 833 | } 834 | } 835 | 836 | #if false 837 | print("") 838 | for line in tokenSets { 839 | let s1 = line.map( { "\($0.token)" }).joined(separator: " ") 840 | let s2 = line.map( { "\(Float(Int(100.0*$0.confidence))/100.0)" }).joined(separator: " ") 841 | print("\(s1): \(s2)") 842 | } 843 | #endif 844 | 845 | // convert the final sets of tokens to a single stream 846 | var resultArray = HoursRecognizer.hoursForTokens( tokenSets ) 847 | resultArray = HoursRecognizer.coalesceDays( resultArray ) 848 | 849 | let resultString = HoursRecognizer.hoursStringForHours( resultArray ) 850 | 851 | // show the selected tokens in the video feed 852 | let invertedTransform = transform.inverted() 853 | let tokenBoxes = tokenSets.joined().map({$0.rect.applying(invertedTransform)}) 854 | camera?.addBoxes(boxes: tokenBoxes, color: UIColor.green) 855 | 856 | #if false 857 | print("\(text)") 858 | #endif 859 | 860 | if resultString != "" { 861 | let count = (resultHistory[resultString] ?? 0) + 1 862 | resultHistory[resultString] = count 863 | 864 | let best = resultHistory.max { $0.value < $1.value }! 865 | 866 | if Thread.isMainThread { 867 | self.text = best.key 868 | self.finished = best.value >= 5 869 | } else { 870 | DispatchQueue.main.async { 871 | self.text = best.key 872 | self.finished = best.value >= 5 873 | } 874 | } 875 | } 876 | } 877 | 878 | func updateWithLiveObservations(observations: [VNRecognizedTextObservation], camera: CameraView?) { 879 | self.updateWithObservations(observations: observations, 880 | transform: CGAffineTransform(scaleX: 1, y: -1).translatedBy(x: 0, y: 1), 881 | camera: camera) 882 | } 883 | 884 | func setImage(image: CGImage, isRotated: Bool) { 885 | self.restart() 886 | 887 | // let rotationTransform = CGAffineTransform(translationX: 0, y: 1).rotated(by: -CGFloat.pi / 2) 888 | 889 | let transform = isRotated ? CGAffineTransform(scaleX: 1.0, y: -1.0).rotated(by: -CGFloat.pi / 2) 890 | : CGAffineTransform.identity 891 | 892 | let request = VNRecognizeTextRequest(completionHandler: { (request, error) in 893 | guard error == nil, 894 | let observations = request.results as? [VNRecognizedTextObservation] else { return } 895 | self.updateWithObservations(observations: observations, transform: transform, camera:nil) 896 | }) 897 | request.recognitionLevel = .accurate 898 | // request.customWords = ["AM","PM"] 899 | // request.usesLanguageCorrection = true 900 | let requestHandler = VNImageRequestHandler(cgImage: image, options: [:]) 901 | try? requestHandler.perform([request]) 902 | } 903 | } 904 | 905 | #if targetEnvironment(macCatalyst) 906 | class BulkProcess { 907 | init() { 908 | } 909 | 910 | func processFile(path:String) { 911 | do { 912 | let userDirectory = try FileManager.default.url(for: FileManager.SearchPathDirectory.downloadsDirectory, in: .userDomainMask, appropriateFor: nil, create: false) 913 | let filePath = userDirectory.appendingPathComponent(path) 914 | let recognizer = HoursRecognizer() 915 | guard let image = UIImage(contentsOfFile: filePath.path), 916 | let cgImage = image.cgImage else { return } 917 | recognizer.setImage(image: cgImage, isRotated: true) 918 | print("\"\(filePath.lastPathComponent)\" => \"\(recognizer.text)\",") 919 | } catch { 920 | print(error.localizedDescription) 921 | } 922 | } 923 | 924 | func processFolder(path:String) { 925 | do { 926 | let userDirectory = try FileManager.default.url(for: FileManager.SearchPathDirectory.downloadsDirectory, in: .userDomainMask, appropriateFor: nil, create: false) 927 | let imageDirectory = userDirectory.appendingPathComponent(path) 928 | let fileList = try FileManager.default.contentsOfDirectory(at: imageDirectory, includingPropertiesForKeys: nil, options: []) 929 | let recognizer = HoursRecognizer() 930 | for fileName in fileList { 931 | // print("\(fileName.lastPathComponent):") 932 | guard let image = UIImage(contentsOfFile: fileName.path), 933 | let cgImage = image.cgImage else { continue } 934 | recognizer.setImage(image: cgImage, isRotated: true) 935 | print("\"\(fileName.lastPathComponent)\" => \"\(recognizer.text)\",") 936 | } 937 | } catch { 938 | print(error.localizedDescription) 939 | } 940 | } 941 | } 942 | #endif 943 | -------------------------------------------------------------------------------- /OpeningHoursPhoto/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | NSCameraUsageDescription 8 | The camera is used to automatically translated business hours in photos 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | LSRequiresIPhoneOS 24 | 25 | UIApplicationSceneManifest 26 | 27 | UIApplicationSupportsMultipleScenes 28 | 29 | 30 | UIApplicationSupportsIndirectInputEvents 31 | 32 | UILaunchScreen 33 | 34 | UIRequiredDeviceCapabilities 35 | 36 | armv7 37 | 38 | UISupportedInterfaceOrientations 39 | 40 | UIInterfaceOrientationPortrait 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | UISupportedInterfaceOrientations~ipad 45 | 46 | UIInterfaceOrientationPortrait 47 | UIInterfaceOrientationPortraitUpsideDown 48 | UIInterfaceOrientationLandscapeLeft 49 | UIInterfaceOrientationLandscapeRight 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /OpeningHoursPhoto/LevenshteinDistance.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LevenshteinDistance.swift 3 | // OpeningHoursPhoto 4 | // 5 | // Created by Bryce Cogswell on 4/20/21. 6 | // 7 | 8 | import Foundation 9 | 10 | func LevenshteinDistance(_ w1: String, _ w2: String) -> Int { 11 | let empty = [Int](repeating:0, count: w2.count) 12 | var last = [Int](0...w2.count) 13 | 14 | for (i, char1) in w1.enumerated() { 15 | var cur = [i + 1] + empty 16 | for (j, char2) in w2.enumerated() { 17 | cur[j + 1] = char1 == char2 ? last[j] : min(last[j], last[j + 1], cur[j]) + 1 18 | } 19 | last = cur 20 | } 21 | return last.last! 22 | } 23 | -------------------------------------------------------------------------------- /OpeningHoursPhoto/OpeningHoursPhoto.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.device.camera 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /OpeningHoursPhoto/OpeningHoursPhoto.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CameraView.swift 3 | // 4 | // Created by Bryce Cogswell on 4/8/21. 5 | // 6 | 7 | import SwiftUI 8 | 9 | 10 | fileprivate extension Button { 11 | func withMyButtonStyle(enabled:Bool) -> some View { 12 | self.padding() 13 | .background(Capsule().fill(enabled ? Color.blue : Color.gray)) 14 | .accentColor(.white) 15 | } 16 | } 17 | 18 | public struct OpeningHoursPhotoView: View { 19 | @Binding var show: Bool 20 | @Binding var returnedText: String 21 | @State private var restart: Bool 22 | 23 | @StateObject var recognizer = HoursRecognizer() 24 | 25 | init( show: Binding, recognizedText: Binding ) { 26 | self._show = show 27 | self._returnedText = recognizedText 28 | self._restart = State(initialValue: false) 29 | } 30 | 31 | public var body: some View { 32 | ZStack(alignment: .topLeading) { 33 | VStack { 34 | OpeningHoursPhoto(recognizer: recognizer, 35 | restart: $restart) 36 | .background(Color.blue) 37 | Spacer() 38 | Text(recognizer.text) 39 | .frame(height: 100.0) 40 | HStack { 41 | Spacer() 42 | Button("Cancel") { 43 | show = false 44 | }.withMyButtonStyle(enabled: true) 45 | Spacer() 46 | Button("Retry") { 47 | restart = true 48 | }.withMyButtonStyle(enabled: true) 49 | Spacer() 50 | Button("Accept") { 51 | show = false 52 | returnedText = recognizer.text 53 | }.withMyButtonStyle( enabled: recognizer.finished ) 54 | .disabled( !recognizer.finished ) 55 | Spacer() 56 | } 57 | } 58 | Picker(recognizer.language.rawValue, selection: $recognizer.language) { 59 | ForEach(HoursRecognizer.Language.allCases) { lang in 60 | Text( lang.name ).tag( lang ) 61 | } 62 | } 63 | .pickerStyle(MenuPickerStyle()) 64 | .foregroundColor(.white) 65 | .padding() 66 | .overlay(Capsule(style: .continuous) 67 | .stroke(Color.white, lineWidth: 2.0)) 68 | } 69 | } 70 | } 71 | 72 | struct OpeningHoursPhoto: UIViewRepresentable { 73 | 74 | @ObservedObject var recognizer: HoursRecognizer 75 | @Binding var restart: Bool 76 | 77 | func makeUIView(context: Context) -> CameraView { 78 | let cam = CameraView(frame: .zero) 79 | cam.observationsCallback = { observations, camera in 80 | recognizer.updateWithLiveObservations( observations: observations, camera: camera ) 81 | } 82 | cam.shouldRecordCallback = { 83 | return !recognizer.finished 84 | } 85 | cam.languages = [recognizer.language.isoCode] 86 | return cam 87 | } 88 | 89 | func updateUIView(_ uiView: CameraView, context: Context) { 90 | if restart { 91 | DispatchQueue.main.async { 92 | restart = false 93 | recognizer.restart() 94 | uiView.startRunning() 95 | } 96 | } 97 | if recognizer.finished { 98 | // TODO: enable Accept button 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /OpeningHoursPhoto/OpeningHoursPhotoApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OpeningHoursPhotoApp.swift 3 | // OpeningHoursPhoto 4 | // 5 | // Created by Bryce Cogswell on 4/9/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct OpeningHoursPhotoApp: App { 12 | init() { 13 | #if targetEnvironment(macCatalyst) 14 | let bulk = BulkProcess() 15 | // bulk.processFolder(path: "OpeningHoursPhotos") 16 | bulk.processFile(path: "opening_hours/deduplicated/IMG_20180517_140507.jpg") 17 | bulk.processFolder(path: "opening_hours/deduplicated") 18 | #endif 19 | } 20 | 21 | var body: some Scene { 22 | WindowGroup { 23 | ContentView() 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /OpeningHoursPhoto/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpeningHoursPhoto 2 | Extract opening hours tags from a camera image 3 | 4 | This project is no longer active. The latest development is taking part as part of [*Go Map!!*](https://github.com/bryceco/GoMap) in the [OpeningHours](https://github.com/bryceco/GoMap/tree/master/src/iOS/OpeningHours) folder. 5 | --------------------------------------------------------------------------------