├── .gitignore ├── PhotoSelectionClassifier.xcodeproj └── project.pbxproj ├── PhotoSelectionClassifier ├── Application │ └── PhotoSelectionClassifierApp.swift ├── CoreData │ ├── Persistence.swift │ └── PhotoSelectionClassifier.xcdatamodeld │ │ ├── .xccurrentversion │ │ └── PhotoSelectionClassifier.xcdatamodel │ │ └── contents ├── Entities │ ├── AlbumPhoto.swift │ ├── Photo.swift │ └── PhotoSelection.swift ├── Info.plist ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Repositories │ ├── AlbumPhotoRepository.swift │ └── PhotoRepository.swift ├── Resources │ └── Assets.xcassets │ │ ├── AccentColor.colorset │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ └── Contents.json │ │ └── Contents.json ├── Services │ ├── PhotoSelectionClassifier.swift │ ├── PhotoSelectionClassifierFileManager.swift │ └── PhotoSelectionTrainer.swift ├── Utilities │ ├── ImageClassifierTrainer.swift │ └── PhotoImageRequester.swift ├── ViewModels │ ├── AlbumGridViewModel.swift │ ├── AlbumViewModel.swift │ ├── PhotoGridViewModel.swift │ └── PhotoPickerViewModel.swift └── Views │ ├── AlbumGridView.swift │ ├── AlbumView.swift │ ├── PhotoGridView.swift │ └── PhotoPickerView.swift └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/xcode 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=xcode 3 | 4 | ### Xcode ### 5 | # Xcode 6 | # 7 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 8 | 9 | ## User settings 10 | xcuserdata/ 11 | 12 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 13 | *.xcscmblueprint 14 | *.xccheckout 15 | 16 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 17 | build/ 18 | DerivedData/ 19 | *.moved-aside 20 | *.pbxuser 21 | !default.pbxuser 22 | *.mode1v3 23 | !default.mode1v3 24 | *.mode2v3 25 | !default.mode2v3 26 | *.perspectivev3 27 | !default.perspectivev3 28 | 29 | ## Gcc Patch 30 | /*.gcno 31 | 32 | ### Xcode Patch ### 33 | *.xcodeproj/* 34 | !*.xcodeproj/project.pbxproj 35 | !*.xcodeproj/xcshareddata/ 36 | !*.xcworkspace/contents.xcworkspacedata 37 | **/xcshareddata/WorkspaceSettings.xcsettings 38 | 39 | # End of https://www.toptal.com/developers/gitignore/api/xcode 40 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 55; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | DF451CF826B6A0DD00FC3346 /* PhotoSelectionClassifierApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF451CF726B6A0DD00FC3346 /* PhotoSelectionClassifierApp.swift */; }; 11 | DF451CFA26B6A0DD00FC3346 /* AlbumView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF451CF926B6A0DD00FC3346 /* AlbumView.swift */; }; 12 | DF451CFC26B6A0DF00FC3346 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DF451CFB26B6A0DF00FC3346 /* Assets.xcassets */; }; 13 | DF451CFF26B6A0DF00FC3346 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DF451CFE26B6A0DF00FC3346 /* Preview Assets.xcassets */; }; 14 | DF451D0126B6A0DF00FC3346 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF451D0026B6A0DF00FC3346 /* Persistence.swift */; }; 15 | DF451D0426B6A0DF00FC3346 /* PhotoSelectionClassifier.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = DF451D0226B6A0DF00FC3346 /* PhotoSelectionClassifier.xcdatamodeld */; }; 16 | DF451D1326B6A19200FC3346 /* AlbumViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF451D0F26B6A19200FC3346 /* AlbumViewModel.swift */; }; 17 | DF451D1426B6A19200FC3346 /* PhotoGridViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF451D1026B6A19200FC3346 /* PhotoGridViewModel.swift */; }; 18 | DF451D1526B6A19200FC3346 /* PhotoPickerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF451D1126B6A19200FC3346 /* PhotoPickerViewModel.swift */; }; 19 | DF451D1626B6A19200FC3346 /* AlbumGridViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF451D1226B6A19200FC3346 /* AlbumGridViewModel.swift */; }; 20 | DF451D1A26B6A1A900FC3346 /* AlbumGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF451D1726B6A1A900FC3346 /* AlbumGridView.swift */; }; 21 | DF451D1B26B6A1A900FC3346 /* PhotoGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF451D1826B6A1A900FC3346 /* PhotoGridView.swift */; }; 22 | DF451D1C26B6A1A900FC3346 /* PhotoPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF451D1926B6A1A900FC3346 /* PhotoPickerView.swift */; }; 23 | DF451D2026B6A1B200FC3346 /* PhotoRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF451D1E26B6A1B200FC3346 /* PhotoRepository.swift */; }; 24 | DF451D2126B6A1B200FC3346 /* AlbumPhotoRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF451D1F26B6A1B200FC3346 /* AlbumPhotoRepository.swift */; }; 25 | DF451D2626B6A1B900FC3346 /* PhotoSelectionTrainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF451D2326B6A1B900FC3346 /* PhotoSelectionTrainer.swift */; }; 26 | DF451D2726B6A1B900FC3346 /* PhotoSelectionClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF451D2426B6A1B900FC3346 /* PhotoSelectionClassifier.swift */; }; 27 | DF451D2826B6A1B900FC3346 /* PhotoSelectionClassifierFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF451D2526B6A1B900FC3346 /* PhotoSelectionClassifierFileManager.swift */; }; 28 | DF451D2D26B6A1C000FC3346 /* PhotoSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF451D2A26B6A1C000FC3346 /* PhotoSelection.swift */; }; 29 | DF451D2E26B6A1C000FC3346 /* AlbumPhoto.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF451D2B26B6A1C000FC3346 /* AlbumPhoto.swift */; }; 30 | DF451D2F26B6A1C000FC3346 /* Photo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF451D2C26B6A1C000FC3346 /* Photo.swift */; }; 31 | DF451D3326B6A1C700FC3346 /* ImageClassifierTrainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF451D3126B6A1C600FC3346 /* ImageClassifierTrainer.swift */; }; 32 | DF451D3426B6A1C700FC3346 /* PhotoImageRequester.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF451D3226B6A1C600FC3346 /* PhotoImageRequester.swift */; }; 33 | /* End PBXBuildFile section */ 34 | 35 | /* Begin PBXFileReference section */ 36 | DF451CF426B6A0DD00FC3346 /* PhotoSelectionClassifier.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PhotoSelectionClassifier.app; sourceTree = BUILT_PRODUCTS_DIR; }; 37 | DF451CF726B6A0DD00FC3346 /* PhotoSelectionClassifierApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoSelectionClassifierApp.swift; sourceTree = ""; }; 38 | DF451CF926B6A0DD00FC3346 /* AlbumView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumView.swift; sourceTree = ""; }; 39 | DF451CFB26B6A0DF00FC3346 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 40 | DF451CFE26B6A0DF00FC3346 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 41 | DF451D0026B6A0DF00FC3346 /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; 42 | DF451D0326B6A0DF00FC3346 /* PhotoSelectionClassifier.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = PhotoSelectionClassifier.xcdatamodel; sourceTree = ""; }; 43 | DF451D0F26B6A19200FC3346 /* AlbumViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlbumViewModel.swift; sourceTree = ""; }; 44 | DF451D1026B6A19200FC3346 /* PhotoGridViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoGridViewModel.swift; sourceTree = ""; }; 45 | DF451D1126B6A19200FC3346 /* PhotoPickerViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoPickerViewModel.swift; sourceTree = ""; }; 46 | DF451D1226B6A19200FC3346 /* AlbumGridViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlbumGridViewModel.swift; sourceTree = ""; }; 47 | DF451D1726B6A1A900FC3346 /* AlbumGridView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlbumGridView.swift; sourceTree = ""; }; 48 | DF451D1826B6A1A900FC3346 /* PhotoGridView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoGridView.swift; sourceTree = ""; }; 49 | DF451D1926B6A1A900FC3346 /* PhotoPickerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoPickerView.swift; sourceTree = ""; }; 50 | DF451D1E26B6A1B200FC3346 /* PhotoRepository.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoRepository.swift; sourceTree = ""; }; 51 | DF451D1F26B6A1B200FC3346 /* AlbumPhotoRepository.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlbumPhotoRepository.swift; sourceTree = ""; }; 52 | DF451D2326B6A1B900FC3346 /* PhotoSelectionTrainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoSelectionTrainer.swift; sourceTree = ""; }; 53 | DF451D2426B6A1B900FC3346 /* PhotoSelectionClassifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoSelectionClassifier.swift; sourceTree = ""; }; 54 | DF451D2526B6A1B900FC3346 /* PhotoSelectionClassifierFileManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoSelectionClassifierFileManager.swift; sourceTree = ""; }; 55 | DF451D2A26B6A1C000FC3346 /* PhotoSelection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoSelection.swift; sourceTree = ""; }; 56 | DF451D2B26B6A1C000FC3346 /* AlbumPhoto.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlbumPhoto.swift; sourceTree = ""; }; 57 | DF451D2C26B6A1C000FC3346 /* Photo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Photo.swift; sourceTree = ""; }; 58 | DF451D3126B6A1C600FC3346 /* ImageClassifierTrainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageClassifierTrainer.swift; sourceTree = ""; }; 59 | DF451D3226B6A1C600FC3346 /* PhotoImageRequester.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoImageRequester.swift; sourceTree = ""; }; 60 | DF451D3526B6A3C200FC3346 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 61 | /* End PBXFileReference section */ 62 | 63 | /* Begin PBXFrameworksBuildPhase section */ 64 | DF451CF126B6A0DD00FC3346 /* Frameworks */ = { 65 | isa = PBXFrameworksBuildPhase; 66 | buildActionMask = 2147483647; 67 | files = ( 68 | ); 69 | runOnlyForDeploymentPostprocessing = 0; 70 | }; 71 | /* End PBXFrameworksBuildPhase section */ 72 | 73 | /* Begin PBXGroup section */ 74 | DF451CEB26B6A0DD00FC3346 = { 75 | isa = PBXGroup; 76 | children = ( 77 | DF451CF626B6A0DD00FC3346 /* PhotoSelectionClassifier */, 78 | DF451CF526B6A0DD00FC3346 /* Products */, 79 | ); 80 | sourceTree = ""; 81 | }; 82 | DF451CF526B6A0DD00FC3346 /* Products */ = { 83 | isa = PBXGroup; 84 | children = ( 85 | DF451CF426B6A0DD00FC3346 /* PhotoSelectionClassifier.app */, 86 | ); 87 | name = Products; 88 | sourceTree = ""; 89 | }; 90 | DF451CF626B6A0DD00FC3346 /* PhotoSelectionClassifier */ = { 91 | isa = PBXGroup; 92 | children = ( 93 | DF451D3526B6A3C200FC3346 /* Info.plist */, 94 | DF451D0A26B6A13700FC3346 /* Resources */, 95 | DF451D0C26B6A15800FC3346 /* Application */, 96 | DF451D0D26B6A16200FC3346 /* Views */, 97 | DF451D0E26B6A19200FC3346 /* ViewModels */, 98 | DF451D1D26B6A1B200FC3346 /* Repositories */, 99 | DF451D2226B6A1B900FC3346 /* Services */, 100 | DF451D2926B6A1C000FC3346 /* Entities */, 101 | DF451D3026B6A1C600FC3346 /* Utilities */, 102 | DF451D0B26B6A14600FC3346 /* CoreData */, 103 | DF451CFD26B6A0DF00FC3346 /* Preview Content */, 104 | ); 105 | path = PhotoSelectionClassifier; 106 | sourceTree = ""; 107 | }; 108 | DF451CFD26B6A0DF00FC3346 /* Preview Content */ = { 109 | isa = PBXGroup; 110 | children = ( 111 | DF451CFE26B6A0DF00FC3346 /* Preview Assets.xcassets */, 112 | ); 113 | path = "Preview Content"; 114 | sourceTree = ""; 115 | }; 116 | DF451D0A26B6A13700FC3346 /* Resources */ = { 117 | isa = PBXGroup; 118 | children = ( 119 | DF451CFB26B6A0DF00FC3346 /* Assets.xcassets */, 120 | ); 121 | path = Resources; 122 | sourceTree = ""; 123 | }; 124 | DF451D0B26B6A14600FC3346 /* CoreData */ = { 125 | isa = PBXGroup; 126 | children = ( 127 | DF451D0026B6A0DF00FC3346 /* Persistence.swift */, 128 | DF451D0226B6A0DF00FC3346 /* PhotoSelectionClassifier.xcdatamodeld */, 129 | ); 130 | path = CoreData; 131 | sourceTree = ""; 132 | }; 133 | DF451D0C26B6A15800FC3346 /* Application */ = { 134 | isa = PBXGroup; 135 | children = ( 136 | DF451CF726B6A0DD00FC3346 /* PhotoSelectionClassifierApp.swift */, 137 | ); 138 | path = Application; 139 | sourceTree = ""; 140 | }; 141 | DF451D0D26B6A16200FC3346 /* Views */ = { 142 | isa = PBXGroup; 143 | children = ( 144 | DF451CF926B6A0DD00FC3346 /* AlbumView.swift */, 145 | DF451D1726B6A1A900FC3346 /* AlbumGridView.swift */, 146 | DF451D1926B6A1A900FC3346 /* PhotoPickerView.swift */, 147 | DF451D1826B6A1A900FC3346 /* PhotoGridView.swift */, 148 | ); 149 | path = Views; 150 | sourceTree = ""; 151 | }; 152 | DF451D0E26B6A19200FC3346 /* ViewModels */ = { 153 | isa = PBXGroup; 154 | children = ( 155 | DF451D0F26B6A19200FC3346 /* AlbumViewModel.swift */, 156 | DF451D1226B6A19200FC3346 /* AlbumGridViewModel.swift */, 157 | DF451D1126B6A19200FC3346 /* PhotoPickerViewModel.swift */, 158 | DF451D1026B6A19200FC3346 /* PhotoGridViewModel.swift */, 159 | ); 160 | path = ViewModels; 161 | sourceTree = ""; 162 | }; 163 | DF451D1D26B6A1B200FC3346 /* Repositories */ = { 164 | isa = PBXGroup; 165 | children = ( 166 | DF451D1E26B6A1B200FC3346 /* PhotoRepository.swift */, 167 | DF451D1F26B6A1B200FC3346 /* AlbumPhotoRepository.swift */, 168 | ); 169 | path = Repositories; 170 | sourceTree = ""; 171 | }; 172 | DF451D2226B6A1B900FC3346 /* Services */ = { 173 | isa = PBXGroup; 174 | children = ( 175 | DF451D2326B6A1B900FC3346 /* PhotoSelectionTrainer.swift */, 176 | DF451D2426B6A1B900FC3346 /* PhotoSelectionClassifier.swift */, 177 | DF451D2526B6A1B900FC3346 /* PhotoSelectionClassifierFileManager.swift */, 178 | ); 179 | path = Services; 180 | sourceTree = ""; 181 | }; 182 | DF451D2926B6A1C000FC3346 /* Entities */ = { 183 | isa = PBXGroup; 184 | children = ( 185 | DF451D2A26B6A1C000FC3346 /* PhotoSelection.swift */, 186 | DF451D2B26B6A1C000FC3346 /* AlbumPhoto.swift */, 187 | DF451D2C26B6A1C000FC3346 /* Photo.swift */, 188 | ); 189 | path = Entities; 190 | sourceTree = ""; 191 | }; 192 | DF451D3026B6A1C600FC3346 /* Utilities */ = { 193 | isa = PBXGroup; 194 | children = ( 195 | DF451D3126B6A1C600FC3346 /* ImageClassifierTrainer.swift */, 196 | DF451D3226B6A1C600FC3346 /* PhotoImageRequester.swift */, 197 | ); 198 | path = Utilities; 199 | sourceTree = ""; 200 | }; 201 | /* End PBXGroup section */ 202 | 203 | /* Begin PBXNativeTarget section */ 204 | DF451CF326B6A0DD00FC3346 /* PhotoSelectionClassifier */ = { 205 | isa = PBXNativeTarget; 206 | buildConfigurationList = DF451D0726B6A0DF00FC3346 /* Build configuration list for PBXNativeTarget "PhotoSelectionClassifier" */; 207 | buildPhases = ( 208 | DF451CF026B6A0DD00FC3346 /* Sources */, 209 | DF451CF126B6A0DD00FC3346 /* Frameworks */, 210 | DF451CF226B6A0DD00FC3346 /* Resources */, 211 | ); 212 | buildRules = ( 213 | ); 214 | dependencies = ( 215 | ); 216 | name = PhotoSelectionClassifier; 217 | productName = PhotoSelectionClassifier; 218 | productReference = DF451CF426B6A0DD00FC3346 /* PhotoSelectionClassifier.app */; 219 | productType = "com.apple.product-type.application"; 220 | }; 221 | /* End PBXNativeTarget section */ 222 | 223 | /* Begin PBXProject section */ 224 | DF451CEC26B6A0DD00FC3346 /* Project object */ = { 225 | isa = PBXProject; 226 | attributes = { 227 | BuildIndependentTargetsInParallel = 1; 228 | LastSwiftUpdateCheck = 1300; 229 | LastUpgradeCheck = 1300; 230 | TargetAttributes = { 231 | DF451CF326B6A0DD00FC3346 = { 232 | CreatedOnToolsVersion = 13.0; 233 | }; 234 | }; 235 | }; 236 | buildConfigurationList = DF451CEF26B6A0DD00FC3346 /* Build configuration list for PBXProject "PhotoSelectionClassifier" */; 237 | compatibilityVersion = "Xcode 13.0"; 238 | developmentRegion = en; 239 | hasScannedForEncodings = 0; 240 | knownRegions = ( 241 | en, 242 | Base, 243 | ); 244 | mainGroup = DF451CEB26B6A0DD00FC3346; 245 | productRefGroup = DF451CF526B6A0DD00FC3346 /* Products */; 246 | projectDirPath = ""; 247 | projectRoot = ""; 248 | targets = ( 249 | DF451CF326B6A0DD00FC3346 /* PhotoSelectionClassifier */, 250 | ); 251 | }; 252 | /* End PBXProject section */ 253 | 254 | /* Begin PBXResourcesBuildPhase section */ 255 | DF451CF226B6A0DD00FC3346 /* Resources */ = { 256 | isa = PBXResourcesBuildPhase; 257 | buildActionMask = 2147483647; 258 | files = ( 259 | DF451CFF26B6A0DF00FC3346 /* Preview Assets.xcassets in Resources */, 260 | DF451CFC26B6A0DF00FC3346 /* Assets.xcassets in Resources */, 261 | ); 262 | runOnlyForDeploymentPostprocessing = 0; 263 | }; 264 | /* End PBXResourcesBuildPhase section */ 265 | 266 | /* Begin PBXSourcesBuildPhase section */ 267 | DF451CF026B6A0DD00FC3346 /* Sources */ = { 268 | isa = PBXSourcesBuildPhase; 269 | buildActionMask = 2147483647; 270 | files = ( 271 | DF451D0126B6A0DF00FC3346 /* Persistence.swift in Sources */, 272 | DF451D1B26B6A1A900FC3346 /* PhotoGridView.swift in Sources */, 273 | DF451D1C26B6A1A900FC3346 /* PhotoPickerView.swift in Sources */, 274 | DF451D1426B6A19200FC3346 /* PhotoGridViewModel.swift in Sources */, 275 | DF451D2826B6A1B900FC3346 /* PhotoSelectionClassifierFileManager.swift in Sources */, 276 | DF451CFA26B6A0DD00FC3346 /* AlbumView.swift in Sources */, 277 | DF451D2E26B6A1C000FC3346 /* AlbumPhoto.swift in Sources */, 278 | DF451D2D26B6A1C000FC3346 /* PhotoSelection.swift in Sources */, 279 | DF451D0426B6A0DF00FC3346 /* PhotoSelectionClassifier.xcdatamodeld in Sources */, 280 | DF451D1526B6A19200FC3346 /* PhotoPickerViewModel.swift in Sources */, 281 | DF451D1326B6A19200FC3346 /* AlbumViewModel.swift in Sources */, 282 | DF451D1626B6A19200FC3346 /* AlbumGridViewModel.swift in Sources */, 283 | DF451D3326B6A1C700FC3346 /* ImageClassifierTrainer.swift in Sources */, 284 | DF451D2F26B6A1C000FC3346 /* Photo.swift in Sources */, 285 | DF451D2726B6A1B900FC3346 /* PhotoSelectionClassifier.swift in Sources */, 286 | DF451D2126B6A1B200FC3346 /* AlbumPhotoRepository.swift in Sources */, 287 | DF451CF826B6A0DD00FC3346 /* PhotoSelectionClassifierApp.swift in Sources */, 288 | DF451D2626B6A1B900FC3346 /* PhotoSelectionTrainer.swift in Sources */, 289 | DF451D2026B6A1B200FC3346 /* PhotoRepository.swift in Sources */, 290 | DF451D1A26B6A1A900FC3346 /* AlbumGridView.swift in Sources */, 291 | DF451D3426B6A1C700FC3346 /* PhotoImageRequester.swift in Sources */, 292 | ); 293 | runOnlyForDeploymentPostprocessing = 0; 294 | }; 295 | /* End PBXSourcesBuildPhase section */ 296 | 297 | /* Begin XCBuildConfiguration section */ 298 | DF451D0526B6A0DF00FC3346 /* Debug */ = { 299 | isa = XCBuildConfiguration; 300 | buildSettings = { 301 | ALWAYS_SEARCH_USER_PATHS = NO; 302 | CLANG_ANALYZER_NONNULL = YES; 303 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 304 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 305 | CLANG_CXX_LIBRARY = "libc++"; 306 | CLANG_ENABLE_MODULES = YES; 307 | CLANG_ENABLE_OBJC_ARC = YES; 308 | CLANG_ENABLE_OBJC_WEAK = YES; 309 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 310 | CLANG_WARN_BOOL_CONVERSION = YES; 311 | CLANG_WARN_COMMA = YES; 312 | CLANG_WARN_CONSTANT_CONVERSION = YES; 313 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 314 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 315 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 316 | CLANG_WARN_EMPTY_BODY = YES; 317 | CLANG_WARN_ENUM_CONVERSION = YES; 318 | CLANG_WARN_INFINITE_RECURSION = YES; 319 | CLANG_WARN_INT_CONVERSION = YES; 320 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 321 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 322 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 323 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 324 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 325 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 326 | CLANG_WARN_STRICT_PROTOTYPES = YES; 327 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 328 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 329 | CLANG_WARN_UNREACHABLE_CODE = YES; 330 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 331 | COPY_PHASE_STRIP = NO; 332 | DEBUG_INFORMATION_FORMAT = dwarf; 333 | ENABLE_STRICT_OBJC_MSGSEND = YES; 334 | ENABLE_TESTABILITY = YES; 335 | GCC_C_LANGUAGE_STANDARD = gnu11; 336 | GCC_DYNAMIC_NO_PIC = NO; 337 | GCC_NO_COMMON_BLOCKS = YES; 338 | GCC_OPTIMIZATION_LEVEL = 0; 339 | GCC_PREPROCESSOR_DEFINITIONS = ( 340 | "DEBUG=1", 341 | "$(inherited)", 342 | ); 343 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 344 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 345 | GCC_WARN_UNDECLARED_SELECTOR = YES; 346 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 347 | GCC_WARN_UNUSED_FUNCTION = YES; 348 | GCC_WARN_UNUSED_VARIABLE = YES; 349 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 350 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 351 | MTL_FAST_MATH = YES; 352 | ONLY_ACTIVE_ARCH = YES; 353 | SDKROOT = iphoneos; 354 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 355 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 356 | }; 357 | name = Debug; 358 | }; 359 | DF451D0626B6A0DF00FC3346 /* Release */ = { 360 | isa = XCBuildConfiguration; 361 | buildSettings = { 362 | ALWAYS_SEARCH_USER_PATHS = NO; 363 | CLANG_ANALYZER_NONNULL = YES; 364 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 365 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 366 | CLANG_CXX_LIBRARY = "libc++"; 367 | CLANG_ENABLE_MODULES = YES; 368 | CLANG_ENABLE_OBJC_ARC = YES; 369 | CLANG_ENABLE_OBJC_WEAK = YES; 370 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 371 | CLANG_WARN_BOOL_CONVERSION = YES; 372 | CLANG_WARN_COMMA = YES; 373 | CLANG_WARN_CONSTANT_CONVERSION = YES; 374 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 375 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 376 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 377 | CLANG_WARN_EMPTY_BODY = YES; 378 | CLANG_WARN_ENUM_CONVERSION = YES; 379 | CLANG_WARN_INFINITE_RECURSION = YES; 380 | CLANG_WARN_INT_CONVERSION = YES; 381 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 382 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 383 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 384 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 385 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 386 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 387 | CLANG_WARN_STRICT_PROTOTYPES = YES; 388 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 389 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 390 | CLANG_WARN_UNREACHABLE_CODE = YES; 391 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 392 | COPY_PHASE_STRIP = NO; 393 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 394 | ENABLE_NS_ASSERTIONS = NO; 395 | ENABLE_STRICT_OBJC_MSGSEND = YES; 396 | GCC_C_LANGUAGE_STANDARD = gnu11; 397 | GCC_NO_COMMON_BLOCKS = YES; 398 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 399 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 400 | GCC_WARN_UNDECLARED_SELECTOR = YES; 401 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 402 | GCC_WARN_UNUSED_FUNCTION = YES; 403 | GCC_WARN_UNUSED_VARIABLE = YES; 404 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 405 | MTL_ENABLE_DEBUG_INFO = NO; 406 | MTL_FAST_MATH = YES; 407 | SDKROOT = iphoneos; 408 | SWIFT_COMPILATION_MODE = wholemodule; 409 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 410 | VALIDATE_PRODUCT = YES; 411 | }; 412 | name = Release; 413 | }; 414 | DF451D0826B6A0DF00FC3346 /* Debug */ = { 415 | isa = XCBuildConfiguration; 416 | buildSettings = { 417 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 418 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 419 | CODE_SIGN_STYLE = Automatic; 420 | CURRENT_PROJECT_VERSION = 1; 421 | DEVELOPMENT_ASSET_PATHS = "\"PhotoSelectionClassifier/Preview Content\""; 422 | DEVELOPMENT_TEAM = ""; 423 | ENABLE_PREVIEWS = YES; 424 | GENERATE_INFOPLIST_FILE = YES; 425 | INFOPLIST_FILE = PhotoSelectionClassifier/Info.plist; 426 | INFOPLIST_KEY_CFBundleExecutable = PhotoSelectionClassifier; 427 | INFOPLIST_KEY_CFBundleName = PhotoSelectionClassifier; 428 | INFOPLIST_KEY_CFBundleVersion = 1; 429 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 430 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 431 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 432 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 433 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 434 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 435 | LD_RUNPATH_SEARCH_PATHS = ( 436 | "$(inherited)", 437 | "@executable_path/Frameworks", 438 | ); 439 | MARKETING_VERSION = 1.0; 440 | PRODUCT_BUNDLE_IDENTIFIER = com.rockname.PhotoSelectionClassifier; 441 | PRODUCT_NAME = "$(TARGET_NAME)"; 442 | SWIFT_EMIT_LOC_STRINGS = YES; 443 | SWIFT_VERSION = 5.0; 444 | TARGETED_DEVICE_FAMILY = "1,2"; 445 | }; 446 | name = Debug; 447 | }; 448 | DF451D0926B6A0DF00FC3346 /* Release */ = { 449 | isa = XCBuildConfiguration; 450 | buildSettings = { 451 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 452 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 453 | CODE_SIGN_STYLE = Automatic; 454 | CURRENT_PROJECT_VERSION = 1; 455 | DEVELOPMENT_ASSET_PATHS = "\"PhotoSelectionClassifier/Preview Content\""; 456 | DEVELOPMENT_TEAM = ""; 457 | ENABLE_PREVIEWS = YES; 458 | GENERATE_INFOPLIST_FILE = YES; 459 | INFOPLIST_FILE = PhotoSelectionClassifier/Info.plist; 460 | INFOPLIST_KEY_CFBundleExecutable = PhotoSelectionClassifier; 461 | INFOPLIST_KEY_CFBundleName = PhotoSelectionClassifier; 462 | INFOPLIST_KEY_CFBundleVersion = 1; 463 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 464 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 465 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 466 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 467 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 468 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 469 | LD_RUNPATH_SEARCH_PATHS = ( 470 | "$(inherited)", 471 | "@executable_path/Frameworks", 472 | ); 473 | MARKETING_VERSION = 1.0; 474 | PRODUCT_BUNDLE_IDENTIFIER = com.rockname.PhotoSelectionClassifier; 475 | PRODUCT_NAME = "$(TARGET_NAME)"; 476 | SWIFT_EMIT_LOC_STRINGS = YES; 477 | SWIFT_VERSION = 5.0; 478 | TARGETED_DEVICE_FAMILY = "1,2"; 479 | }; 480 | name = Release; 481 | }; 482 | /* End XCBuildConfiguration section */ 483 | 484 | /* Begin XCConfigurationList section */ 485 | DF451CEF26B6A0DD00FC3346 /* Build configuration list for PBXProject "PhotoSelectionClassifier" */ = { 486 | isa = XCConfigurationList; 487 | buildConfigurations = ( 488 | DF451D0526B6A0DF00FC3346 /* Debug */, 489 | DF451D0626B6A0DF00FC3346 /* Release */, 490 | ); 491 | defaultConfigurationIsVisible = 0; 492 | defaultConfigurationName = Release; 493 | }; 494 | DF451D0726B6A0DF00FC3346 /* Build configuration list for PBXNativeTarget "PhotoSelectionClassifier" */ = { 495 | isa = XCConfigurationList; 496 | buildConfigurations = ( 497 | DF451D0826B6A0DF00FC3346 /* Debug */, 498 | DF451D0926B6A0DF00FC3346 /* Release */, 499 | ); 500 | defaultConfigurationIsVisible = 0; 501 | defaultConfigurationName = Release; 502 | }; 503 | /* End XCConfigurationList section */ 504 | 505 | /* Begin XCVersionGroup section */ 506 | DF451D0226B6A0DF00FC3346 /* PhotoSelectionClassifier.xcdatamodeld */ = { 507 | isa = XCVersionGroup; 508 | children = ( 509 | DF451D0326B6A0DF00FC3346 /* PhotoSelectionClassifier.xcdatamodel */, 510 | ); 511 | currentVersion = DF451D0326B6A0DF00FC3346 /* PhotoSelectionClassifier.xcdatamodel */; 512 | path = PhotoSelectionClassifier.xcdatamodeld; 513 | sourceTree = ""; 514 | versionGroupType = wrapper.xcdatamodel; 515 | }; 516 | /* End XCVersionGroup section */ 517 | }; 518 | rootObject = DF451CEC26B6A0DD00FC3346 /* Project object */; 519 | } 520 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/Application/PhotoSelectionClassifierApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Photos 3 | 4 | @main 5 | struct PhotoSelectionClassifierApp: App { 6 | @Environment(\.scenePhase) private var scenePhase 7 | 8 | private let persistenceController = PersistenceController.shared 9 | 10 | var body: some Scene { 11 | WindowGroup { 12 | AlbumView(viewModel: AlbumViewModel()) 13 | .onAppear { 14 | PHPhotoLibrary.requestAuthorization(for: .readWrite) { _ in } 15 | } 16 | .onChange(of: scenePhase) { phase in 17 | switch phase { 18 | case .active, .inactive: break 19 | case .background: persistenceController.saveContext() 20 | @unknown default: fatalError() 21 | } 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/CoreData/Persistence.swift: -------------------------------------------------------------------------------- 1 | import CoreData 2 | 3 | struct PersistenceController { 4 | static let shared = PersistenceController() 5 | 6 | let container: NSPersistentContainer 7 | 8 | private init(inMemory: Bool = false) { 9 | container = NSPersistentContainer(name: "PhotoSelectionClassifier") 10 | if inMemory { 11 | container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") 12 | } 13 | container.loadPersistentStores(completionHandler: { (storeDescription, error) in 14 | if let error = error as NSError? { 15 | fatalError("Unresolved error \(error), \(error.userInfo)") 16 | } 17 | }) 18 | } 19 | 20 | func saveContext() { 21 | let context = container.viewContext 22 | if context.hasChanges { 23 | do { 24 | try context.save() 25 | } catch { 26 | if let error = error as NSError? { 27 | fatalError("Unresolved error \(error), \(error.userInfo)") 28 | } 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/CoreData/PhotoSelectionClassifier.xcdatamodeld/.xccurrentversion: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | _XCCurrentVersionName 6 | PhotoSelectionClassifier.xcdatamodel 7 | 8 | 9 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/CoreData/PhotoSelectionClassifier.xcdatamodeld/PhotoSelectionClassifier.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/Entities/AlbumPhoto.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Photos 3 | 4 | struct AlbumPhoto: Identifiable, Codable { 5 | let id: UUID 6 | let localIdentifier: String 7 | let takenAt: Date 8 | } 9 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/Entities/Photo.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Photos 3 | 4 | struct Photo: Hashable { 5 | let phAsset: PHAsset 6 | 7 | init(phAsset: PHAsset) { 8 | self.phAsset = phAsset 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/Entities/PhotoSelection.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Photos 3 | 4 | struct PhotoSelection { 5 | let photo: Photo 6 | let isSelected: Bool 7 | } 8 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LSRequiresIPhoneOS 6 | 7 | NSPhotoLibraryUsageDescription 8 | This app requires access to the Photos app. 9 | 10 | 11 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/Repositories/AlbumPhotoRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreData 3 | 4 | protocol AlbumPhotoRepository { 5 | func fetchAlbumPhotos() async throws -> [AlbumPhoto] 6 | func sharePhotosToAlbum(photos: [Photo]) async throws 7 | func fetchOldestAndLatestTakenAt() async throws -> (oldest: Date, latest: Date)? 8 | } 9 | 10 | struct AlbumPhotoDataRepository: AlbumPhotoRepository { 11 | enum AlbumPhotoError: Error { 12 | case batchInsertError 13 | } 14 | 15 | private let context: NSManagedObjectContext 16 | 17 | init(context: NSManagedObjectContext = PersistenceController.shared.container.viewContext) { 18 | self.context = context 19 | } 20 | 21 | func fetchAlbumPhotos() async throws -> [AlbumPhoto] { 22 | try await context.perform { 23 | let request = NSFetchRequest(entityName: String(describing: CoreDataAlbumPhoto.self)) 24 | let fetchResult = try request.execute() 25 | return fetchResult.compactMap { 26 | AlbumPhoto( 27 | id: $0.id!, 28 | localIdentifier: $0.localIdentifier!, 29 | takenAt: $0.takenAt! 30 | ) 31 | } 32 | } 33 | } 34 | 35 | func fetchOldestAndLatestTakenAt() async throws -> (oldest: Date, latest: Date)? { 36 | try await context.perform { 37 | guard 38 | let oldestAlbumPhoto = try fetchTop(ascending: true), 39 | let latestAlbumPhoto = try fetchTop(ascending: false) 40 | else { 41 | return nil 42 | } 43 | 44 | return ( 45 | oldest: oldestAlbumPhoto.takenAt, 46 | latest: latestAlbumPhoto.takenAt 47 | ) 48 | } 49 | } 50 | 51 | func sharePhotosToAlbum(photos: [Photo]) async throws { 52 | try await context.perform { 53 | var index = 0 54 | let total = photos.count 55 | 56 | let insertRequest = NSBatchInsertRequest( 57 | entity: CoreDataAlbumPhoto.entity() 58 | ) { (managedObject: NSManagedObject) in 59 | guard index < total else { return true } 60 | 61 | if let albumPhoto = managedObject as? CoreDataAlbumPhoto { 62 | let data = photos[index] 63 | albumPhoto.id = UUID() 64 | albumPhoto.localIdentifier = data.phAsset.localIdentifier 65 | albumPhoto.takenAt = data.phAsset.creationDate! 66 | } 67 | index += 1 68 | return false 69 | } 70 | let fetchResult = try context.execute(insertRequest) 71 | guard 72 | let batchInsertResult = fetchResult as? NSBatchInsertResult, 73 | let success = batchInsertResult.result as? Bool, 74 | success 75 | else { 76 | throw AlbumPhotoError.batchInsertError 77 | } 78 | } 79 | } 80 | 81 | private func fetchTop(ascending: Bool) throws -> AlbumPhoto? { 82 | let request = NSFetchRequest(entityName: String(describing: CoreDataAlbumPhoto.self)) 83 | let sortDescriptor = NSSortDescriptor(key: "takenAt", ascending: ascending) 84 | request.sortDescriptors = [sortDescriptor] 85 | request.fetchLimit = 1 86 | let fetchResult = try request.execute() 87 | return fetchResult.compactMap { 88 | AlbumPhoto( 89 | id: $0.id!, 90 | localIdentifier: $0.localIdentifier!, 91 | takenAt: $0.takenAt! 92 | ) 93 | }.first 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/Repositories/PhotoRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Photos 3 | 4 | protocol PhotoRepository { 5 | func fetchPhotos(excludingLocalIdentifiers: [String]) -> [Photo] 6 | func fetchPhotos(with localIdentifiers: [String]) -> [Photo] 7 | func fetchPhoto(with localIdentifier: String) -> Photo? 8 | func fetchPhotos(from startDate: Date, to endDate: Date) -> [Photo] 9 | } 10 | 11 | struct PhotoDataRepository: PhotoRepository { 12 | func fetchPhotos(excludingLocalIdentifiers: [String]) -> [Photo] { 13 | let options = PHFetchOptions() 14 | options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] 15 | options.predicate = NSPredicate(format:"NOT (localIdentifier IN %@)", excludingLocalIdentifiers) 16 | let result = PHAsset.fetchAssets(with: options) 17 | var photos = [Photo]() 18 | result.enumerateObjects { (asset, _, _) in 19 | photos.append(Photo(phAsset: asset)) 20 | } 21 | return photos 22 | } 23 | 24 | func fetchPhotos(with localIdentifiers: [String]) -> [Photo] { 25 | let options = PHFetchOptions() 26 | options.predicate = NSPredicate( 27 | format: "mediaType = %d", 28 | PHAssetMediaType.image.rawValue 29 | ) 30 | options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] 31 | let result = PHAsset.fetchAssets(withLocalIdentifiers: localIdentifiers, options: options) 32 | var photos = [Photo]() 33 | result.enumerateObjects { (asset, _, _) in 34 | photos.append(Photo(phAsset: asset)) 35 | } 36 | return photos 37 | } 38 | 39 | func fetchPhoto(with localIdentifier: String) -> Photo? { 40 | let options = PHFetchOptions() 41 | options.predicate = NSPredicate( 42 | format: "mediaType = %d", 43 | PHAssetMediaType.image.rawValue 44 | ) 45 | options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] 46 | let result = PHAsset.fetchAssets(withLocalIdentifiers: [localIdentifier], options: options) 47 | return result.firstObject.map(Photo.init(phAsset:)) 48 | } 49 | 50 | func fetchPhotos(from fromDate: Date, to toDate: Date) -> [Photo] { 51 | let options = PHFetchOptions() 52 | options.predicate = NSPredicate( 53 | format: "mediaType = %d AND (creationDate >= %@) AND (creationDate <= %@)", 54 | PHAssetMediaType.image.rawValue, 55 | fromDate as NSDate, 56 | toDate as NSDate 57 | ) 58 | let result = PHAsset.fetchAssets(with: options) 59 | var photos = [Photo]() 60 | result.enumerateObjects { (asset, _, _) in 61 | photos.append(Photo(phAsset: asset)) 62 | } 63 | return photos 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/Resources/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 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/Resources/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 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/Services/PhotoSelectionClassifier.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreML 3 | import Vision 4 | import CoreImage 5 | 6 | enum PhotoSelectionLabel: String { 7 | case selected = "Selected" 8 | case notSelected = "NotSelected" 9 | } 10 | 11 | struct PhotoSelectionImageClassifier { 12 | private let classifierFileManager: PhotoSelectionClassifierFileManager 13 | 14 | init(classifierFileManager: PhotoSelectionClassifierFileManager = .init()) { 15 | self.classifierFileManager = classifierFileManager 16 | } 17 | 18 | func execute(image: CGImage) async throws -> PhotoSelectionLabel { 19 | typealias ClassifiedResultContinuation = CheckedContinuation 20 | return try await withCheckedThrowingContinuation { (continuation: ClassifiedResultContinuation) in 21 | do { 22 | let modelURL = classifierFileManager.compiledClassifierURL 23 | let model = try MLModel(contentsOf: modelURL) 24 | let classifier = try VNCoreMLModel(for: model) 25 | let request = VNCoreMLRequest(model: classifier) { request, error in 26 | guard let results = request.results else { 27 | continuation.resume(throwing: error!) 28 | return 29 | } 30 | 31 | let classification = results.first! as! VNClassificationObservation 32 | continuation.resume(returning: PhotoSelectionLabel(rawValue: classification.identifier)!) 33 | } 34 | request.imageCropAndScaleOption = .centerCrop 35 | DispatchQueue.global(qos: .userInitiated).async { 36 | let handler = VNImageRequestHandler(cgImage: image, orientation: .up) 37 | do { 38 | try handler.perform([request]) 39 | } catch { 40 | continuation.resume(throwing: error) 41 | } 42 | } 43 | } catch { 44 | continuation.resume(throwing: error) 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/Services/PhotoSelectionClassifierFileManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct PhotoSelectionClassifierFileManager { 4 | private struct Constants { 5 | static let trainingDataPath = "TrainingData" 6 | static let selectedDataPath = "Selected" 7 | static let notSelectedDataPath = "NotSelected" 8 | static let classifierPath = "Classifier" 9 | static let classifierFileName = "PhotoSelectionClassifier.mlmodel" 10 | static let compiledClassifierFileName = "PhotoSelectionClassifier.mlmodelc" 11 | } 12 | 13 | private let fileManager: FileManager 14 | 15 | private var applicationSupportDirectory: URL { 16 | fileManager.urls( 17 | for: .applicationSupportDirectory, 18 | in: .userDomainMask 19 | ).first! 20 | } 21 | 22 | var trainingDataDirectory: URL { 23 | applicationSupportDirectory.appendingPathComponent(Constants.trainingDataPath) 24 | } 25 | 26 | var selectedDataDirectory: URL { 27 | trainingDataDirectory.appendingPathComponent(Constants.selectedDataPath) 28 | } 29 | 30 | var notSelectedDataDirectory: URL { trainingDataDirectory.appendingPathComponent(Constants.notSelectedDataPath) 31 | } 32 | 33 | var classifierURL: URL { 34 | applicationSupportDirectory 35 | .appendingPathComponent(Constants.classifierPath) 36 | .appendingPathComponent(Constants.classifierFileName) 37 | } 38 | 39 | var compiledClassifierURL: URL { 40 | applicationSupportDirectory 41 | .appendingPathComponent(Constants.classifierPath) 42 | .appendingPathComponent(Constants.compiledClassifierFileName) 43 | } 44 | 45 | var compiledClassifierFileExists: Bool { 46 | fileManager.fileExists(atPath: compiledClassifierURL.path) 47 | } 48 | 49 | init(fileManager: FileManager = .default) { 50 | self.fileManager = fileManager 51 | } 52 | 53 | func cleanTrainingDataDirectory() throws { 54 | if fileManager.fileExists(atPath: trainingDataDirectory.path) { 55 | try fileManager.removeItem(at: trainingDataDirectory) 56 | } 57 | try [selectedDataDirectory, notSelectedDataDirectory].forEach { url in 58 | try fileManager.createDirectory( 59 | at: url, 60 | withIntermediateDirectories: true 61 | ) 62 | } 63 | } 64 | 65 | @discardableResult 66 | func preserveTemporaryClassifierURL(_ temporaryClassifierURL: URL) throws -> URL? { 67 | let file = try fileManager.replaceItemAt( 68 | compiledClassifierURL, 69 | withItemAt: temporaryClassifierURL 70 | ) 71 | print("Compiled model successfully saved at \(String(describing: file))") 72 | return file 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/Services/PhotoSelectionTrainer.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Photos 3 | import CreateML 4 | import CoreML 5 | 6 | actor PhotoSelectionTrainer { 7 | private let albumPhotoRepository: AlbumPhotoRepository 8 | private let photoRepository: PhotoRepository 9 | private let photoImageRequester: PhotoImageRequester 10 | private let classifierFileManager: PhotoSelectionClassifierFileManager 11 | 12 | init( 13 | albumPhotoRepository: AlbumPhotoRepository = AlbumPhotoDataRepository(), 14 | photoRepository: PhotoRepository = PhotoDataRepository(), 15 | photoImageRequester: PhotoImageRequester = .init(), 16 | classifierFileManager: PhotoSelectionClassifierFileManager = .init() 17 | ) { 18 | self.albumPhotoRepository = albumPhotoRepository 19 | self.photoRepository = photoRepository 20 | self.photoImageRequester = photoImageRequester 21 | self.classifierFileManager = classifierFileManager 22 | } 23 | 24 | func execute(selectedLocalIdentifiers: [String]) async { 25 | do { 26 | print("👀 Checking if enough training data") 27 | var photoSelections = try await fetchPhotoSelections(selectedLocalIdentifiers: selectedLocalIdentifiers) 28 | let selectedCount = photoSelections.filter({ $0.isSelected }).count 29 | let notSelectedCount = photoSelections.count - selectedCount 30 | print("🚩 Selected: \(selectedCount), Not Selected: \(notSelectedCount)") 31 | let minPhotoSelectionCount = min(selectedCount, notSelectedCount) 32 | guard minPhotoSelectionCount >= 20 else { 33 | print("❎ Not enough training data yet") 34 | return 35 | } 36 | 37 | print("⛰ Aligning the number of Selected / Not selected to the smaller one") 38 | photoSelections = filterPhotoSelections(photoSelections, limit: minPhotoSelectionCount) 39 | 40 | print(""" 41 | ⚡️ Preprocessing training data 42 | - Fetching image data 43 | - Resizing images to minimum required size 44 | - Outputing image data to the training data directory 45 | """ 46 | ) 47 | try classifierFileManager.cleanTrainingDataDirectory() 48 | let photoSelectionImagesFetcher = PhotoSelectionImagesFetcher( 49 | photoSelections: photoSelections, 50 | photoImageRequester: photoImageRequester 51 | ) 52 | for try await result in photoSelectionImagesFetcher { 53 | let (photoSelection, image) = result 54 | let resizedImage = resizeImage(image, targetSize: PhotoSelectionTrainer.minimumTrainedImageSize) 55 | let imageURL = ( 56 | photoSelection.isSelected 57 | ? classifierFileManager.selectedDataDirectory 58 | : classifierFileManager.notSelectedDataDirectory 59 | ) 60 | .appendingPathComponent("\(UUID().uuidString).jpg") 61 | let imageData = resizedImage.jpegData(compressionQuality: 1.0)! 62 | try imageData.write(to: imageURL) 63 | } 64 | 65 | print("🤖 Training photo selections") 66 | let parameters = MLImageClassifier.ModelParameters( 67 | featureExtractor: .scenePrint(revision: 1), 68 | validationData: nil, 69 | maxIterations: 20, 70 | augmentationOptions: [.crop, .blur, .exposure, .flip, .noise, .rotation] 71 | ) 72 | let trainer = ImageClassifierTrainer() 73 | let imageClassifier = try await trainer.execute( 74 | trainingDataDirectory: classifierFileManager.trainingDataDirectory, 75 | modelParameters: parameters 76 | ) 77 | print("🔧 Compiling a trained model and saving permanently") 78 | let imageClassifierURL = classifierFileManager.classifierURL 79 | try imageClassifier.write( 80 | to: imageClassifierURL, 81 | metadata: .init( 82 | author: "rockname", 83 | shortDescription: "Photo Selection Classifier" 84 | ) 85 | ) 86 | let temporaryClassifierURL = try MLModel.compileModel(at: imageClassifierURL) 87 | try classifierFileManager.preserveTemporaryClassifierURL(temporaryClassifierURL) 88 | } catch { 89 | print(error) 90 | } 91 | } 92 | 93 | private func fetchPhotoSelections(selectedLocalIdentifiers: [String]) async throws -> [PhotoSelection] { 94 | guard let (oldest, latest) = try await albumPhotoRepository.fetchOldestAndLatestTakenAt() else { return [] } 95 | 96 | let targetPhotos = photoRepository.fetchPhotos(from: oldest, to: latest) 97 | return targetPhotos.map { photo in 98 | if selectedLocalIdentifiers.contains(where: { selectedLocalIdentifier in 99 | selectedLocalIdentifier == photo.phAsset.localIdentifier 100 | }) { 101 | return PhotoSelection(photo: photo, isSelected: true) 102 | } else { 103 | return PhotoSelection(photo: photo, isSelected: false) 104 | } 105 | } 106 | } 107 | 108 | private func filterPhotoSelections(_ photoSelections: [PhotoSelection], limit: Int) -> [PhotoSelection] { 109 | let descendingPhotoSelections = photoSelections.sorted { $0.photo.phAsset.creationDate! > $1.photo.phAsset.creationDate! } 110 | var selected = [PhotoSelection]() 111 | var notSelected = [PhotoSelection]() 112 | for photoSelection in descendingPhotoSelections { 113 | if photoSelection.isSelected { 114 | selected.append(photoSelection) 115 | } else { 116 | notSelected.append(photoSelection) 117 | } 118 | 119 | if [selected.count, notSelected.count].contains(where: { count in 120 | count >= limit 121 | }) { 122 | break 123 | } 124 | } 125 | return selected + notSelected 126 | } 127 | 128 | private func fetchTargetImages(photoSelections: [PhotoSelection]) async throws -> [(photoSelection: PhotoSelection, image: UIImage)] { 129 | var targetImages = [(photoSelection: PhotoSelection, image: UIImage)]() 130 | try await withThrowingTaskGroup(of: (PhotoSelection, UIImage).self) { [photoImageRequester] group in 131 | for photoSelection in photoSelections { 132 | group.addTask { 133 | return ( 134 | photoSelection, 135 | try await photoImageRequester.execute( 136 | phAsset: photoSelection.photo.phAsset, 137 | targetSize: PHImageManagerMaximumSize, 138 | deliveryMode: .highQualityFormat 139 | ) 140 | ) 141 | } 142 | 143 | for try await (photoSelection, image) in group { 144 | targetImages.append((photoSelection: photoSelection, image: image)) 145 | } 146 | } 147 | } 148 | return targetImages 149 | } 150 | 151 | private func resizeImage(_ image: UIImage, targetSize: CGSize) -> UIImage { 152 | let size = image.size 153 | 154 | let widthRatio = targetSize.width / size.width 155 | let heightRatio = targetSize.height / size.height 156 | 157 | let newSize: CGSize 158 | if widthRatio > heightRatio { 159 | newSize = CGSize(width: size.width * heightRatio, height: size.height * heightRatio) 160 | } else { 161 | newSize = CGSize(width: size.width * widthRatio, height: size.height * widthRatio) 162 | } 163 | 164 | return UIGraphicsImageRenderer(size: newSize).image { (context) in 165 | image.draw(in: CGRect(origin: .zero, size: newSize)) 166 | } 167 | } 168 | 169 | private struct PhotoSelectionImagesFetcher: AsyncSequence, AsyncIteratorProtocol { 170 | typealias Element = (photoSelection: PhotoSelection, image: UIImage) 171 | 172 | private var index = 0 173 | 174 | let photoSelections: [PhotoSelection] 175 | let photoImageRequester: PhotoImageRequester 176 | 177 | init( 178 | photoSelections: [PhotoSelection], 179 | photoImageRequester: PhotoImageRequester 180 | ) { 181 | self.photoSelections = photoSelections 182 | self.photoImageRequester = photoImageRequester 183 | } 184 | 185 | mutating func next() async throws -> (photoSelection: PhotoSelection, image: UIImage)? { 186 | defer { index += 1 } 187 | 188 | if index >= photoSelections.count - 1 { 189 | return nil 190 | } else { 191 | return ( 192 | photoSelection: photoSelections[index], 193 | image: try await photoImageRequester.execute( 194 | phAsset: photoSelections[index].photo.phAsset, 195 | targetSize: PhotoSelectionTrainer.minimumTrainedImageSize 196 | ) 197 | ) 198 | } 199 | } 200 | 201 | func makeAsyncIterator() -> PhotoSelectionImagesFetcher { self } 202 | } 203 | } 204 | 205 | extension PhotoSelectionTrainer { 206 | static let minimumTrainedImageSize = CGSize(width: 300, height: 300) 207 | } 208 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/Utilities/ImageClassifierTrainer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CreateML 3 | import Combine 4 | 5 | struct ImageClassifierTrainer { 6 | func execute( 7 | trainingDataDirectory: URL, 8 | modelParameters: MLImageClassifier.ModelParameters 9 | ) async throws -> MLImageClassifier { 10 | var cancellables = Set() 11 | 12 | typealias TrainedImageClassifierContinuation = CheckedContinuation 13 | return try await withTaskCancellationHandler(handler: { 14 | print("cancelled") 15 | // https://forums.swift.org/t/how-to-cancel-a-publisher-when-using-withtaskcancellationhandler/49688 16 | // cancellables.removeAll() 17 | }, operation: { 18 | return try await withCheckedThrowingContinuation { (continuation: TrainedImageClassifierContinuation) in 19 | do { 20 | let job = try MLImageClassifier.train( 21 | trainingData: .labeledDirectories(at: trainingDataDirectory), 22 | parameters: modelParameters 23 | ) 24 | job.result 25 | .sink { completion in 26 | switch completion { 27 | case .failure(let error): continuation.resume(throwing: error) 28 | case .finished: print("Finished to train an image classifier") 29 | } 30 | } receiveValue: { imageClassifier in 31 | continuation.resume(returning: imageClassifier) 32 | } 33 | .store(in: &cancellables) 34 | job.progress.publisher(for: \.fractionCompleted).sink { _ in 35 | guard let progress = MLProgress(progress: job.progress) else { return } 36 | 37 | print("Phase: \(progress.phase)") 38 | print("Processed Item Count: \(progress.itemCount)") 39 | } 40 | .store(in: &cancellables) 41 | } catch { 42 | continuation.resume(throwing: error) 43 | } 44 | } 45 | }) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/Utilities/PhotoImageRequester.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Photos 3 | 4 | struct PhotoImageRequester { 5 | enum PhotoImageRequesterError: Error { 6 | case failedToFetchImage 7 | } 8 | 9 | private let phImageManager: PHImageManager 10 | 11 | init(phImageManager: PHImageManager = .default()) { 12 | self.phImageManager = phImageManager 13 | } 14 | 15 | func execute( 16 | phAsset: PHAsset, 17 | targetSize: CGSize, 18 | deliveryMode: PHImageRequestOptionsDeliveryMode = .highQualityFormat 19 | ) async throws -> UIImage { 20 | typealias RequestedImageContinuation = CheckedContinuation 21 | return try await withCheckedThrowingContinuation { (continuation: RequestedImageContinuation) in 22 | let options: PHImageRequestOptions = { 23 | let options = PHImageRequestOptions() 24 | options.deliveryMode = deliveryMode 25 | return options 26 | }() 27 | phImageManager.requestImage( 28 | for: phAsset, 29 | targetSize: targetSize, 30 | contentMode: .aspectFill, 31 | options: options 32 | ) { (image, info) in 33 | guard let image = image else { 34 | continuation.resume(throwing: PhotoImageRequesterError.failedToFetchImage) 35 | return 36 | } 37 | 38 | continuation.resume(returning: image) 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/ViewModels/AlbumGridViewModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Photos 3 | 4 | class AlbumGridViewModel: ObservableObject { 5 | private let albumPhoto: AlbumPhoto 6 | private let photoRepository: PhotoRepository 7 | private let photoImageRequester: PhotoImageRequester 8 | 9 | @Published var image: UIImage? 10 | 11 | init( 12 | albumPhoto: AlbumPhoto, 13 | photoRepository: PhotoRepository = PhotoDataRepository(), 14 | photoImageRequester: PhotoImageRequester = .init() 15 | ) { 16 | self.albumPhoto = albumPhoto 17 | self.photoRepository = photoRepository 18 | self.photoImageRequester = photoImageRequester 19 | } 20 | 21 | func onAppear() async { 22 | guard let photo = photoRepository.fetchPhoto(with: albumPhoto.localIdentifier) else { return } 23 | 24 | do { 25 | image = try await photoImageRequester.execute( 26 | phAsset: photo.phAsset, 27 | targetSize: PhotoSelectionTrainer.minimumTrainedImageSize 28 | ) 29 | } catch { 30 | print(error) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/ViewModels/AlbumViewModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | class AlbumViewModel: ObservableObject { 4 | private let albumPhotoRepository: AlbumPhotoRepository 5 | private let photoSelectionTrainer: PhotoSelectionTrainer 6 | 7 | private var trainingTask: Task<(), Never>? 8 | 9 | @Published var albumPhotos = [AlbumPhoto]() 10 | 11 | init( 12 | albumPhotoRepository: AlbumPhotoRepository = AlbumPhotoDataRepository(), 13 | photoSelectionTrainer: PhotoSelectionTrainer = .init() 14 | ) { 15 | self.albumPhotoRepository = albumPhotoRepository 16 | self.photoSelectionTrainer = photoSelectionTrainer 17 | } 18 | 19 | func onAppear() async { 20 | await loadAlbumPhotos() 21 | await startTraining() 22 | } 23 | 24 | func onPhotoPickerViewDismissed() async { 25 | await loadAlbumPhotos() 26 | await startTraining() 27 | } 28 | 29 | private func loadAlbumPhotos() async { 30 | do { 31 | albumPhotos = try await albumPhotoRepository.fetchAlbumPhotos() 32 | } catch { 33 | print(error) 34 | } 35 | } 36 | 37 | private func startTraining() async { 38 | trainingTask?.cancel() 39 | trainingTask = Task { 40 | return await photoSelectionTrainer.execute(selectedLocalIdentifiers: albumPhotos.map { $0.localIdentifier }) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/ViewModels/PhotoGridViewModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Photos 3 | 4 | class PhotoGridViewModel: ObservableObject { 5 | private let photoImageRequester: PhotoImageRequester 6 | private var shouldAutoSelectOnAppear = false 7 | 8 | @Published var image: UIImage? 9 | @Published var isSelected = false 10 | 11 | let photo: Photo 12 | 13 | init( 14 | photo: Photo, 15 | photoImageRequester: PhotoImageRequester = .init() 16 | ) { 17 | self.photo = photo 18 | self.photoImageRequester = photoImageRequester 19 | } 20 | 21 | func onAppear() async { 22 | do { 23 | let image = try await photoImageRequester.execute( 24 | phAsset: photo.phAsset, 25 | targetSize: PhotoSelectionTrainer.minimumTrainedImageSize 26 | ) 27 | if shouldAutoSelectOnAppear { 28 | try await classifyPhotoSelection(image: image.cgImage!) 29 | } 30 | self.image = image 31 | } catch { 32 | print(error) 33 | } 34 | } 35 | 36 | func onPhotoGridTapped() { 37 | withAnimation { 38 | isSelected.toggle() 39 | } 40 | } 41 | 42 | func onAutoSelectButtonTapped() async throws { 43 | if let image = image { 44 | do { 45 | try await classifyPhotoSelection(image: image.cgImage!) 46 | } catch { 47 | print(error) 48 | } 49 | } else { 50 | shouldAutoSelectOnAppear = true 51 | } 52 | } 53 | 54 | private func classifyPhotoSelection(image: CGImage) async throws { 55 | let result = try await PhotoSelectionImageClassifier().execute(image: image) 56 | await reflectPhotoSelection(result) 57 | } 58 | 59 | @MainActor 60 | private func reflectPhotoSelection(_ photoSelection: PhotoSelectionLabel) { 61 | withAnimation { 62 | switch photoSelection { 63 | case .selected: isSelected = true 64 | case .notSelected: isSelected = false 65 | } 66 | } 67 | } 68 | } 69 | 70 | extension PhotoGridViewModel: Identifiable { 71 | var id: String { 72 | photo.phAsset.localIdentifier 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/ViewModels/PhotoPickerViewModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Photos 3 | import CreateML 4 | 5 | class PhotoPickerViewModel: ObservableObject { 6 | private let albumPhotoRepository: AlbumPhotoRepository 7 | private let photoRepository: PhotoRepository 8 | private let classifierFileManager: PhotoSelectionClassifierFileManager 9 | 10 | @Published var photoGridViewModels = [PhotoGridViewModel]() 11 | @Published var shouldDismiss: Bool = false 12 | @Published var showsAutoSelectButton: Bool = false 13 | 14 | init( 15 | albumPhotoRepository: AlbumPhotoRepository = AlbumPhotoDataRepository(), 16 | photoRepository: PhotoRepository = PhotoDataRepository(), 17 | classifierFileManager: PhotoSelectionClassifierFileManager = .init() 18 | ) { 19 | self.albumPhotoRepository = albumPhotoRepository 20 | self.photoRepository = photoRepository 21 | self.classifierFileManager = classifierFileManager 22 | } 23 | 24 | func onAppear() async { 25 | do { 26 | let albumPhotos = try await albumPhotoRepository.fetchAlbumPhotos() 27 | let excludingLocalIdentifiers = albumPhotos.map { $0.localIdentifier } 28 | photoGridViewModels = photoRepository.fetchPhotos(excludingLocalIdentifiers: excludingLocalIdentifiers) 29 | .map { photo in 30 | PhotoGridViewModel(photo: photo) 31 | } 32 | showsAutoSelectButton = classifierFileManager.compiledClassifierFileExists 33 | } catch { 34 | print(error) 35 | } 36 | } 37 | 38 | func onShareButtonTapped() async { 39 | do { 40 | let selectedPhotos = photoGridViewModels.filter { $0.isSelected }.map { $0.photo } 41 | try await albumPhotoRepository.sharePhotosToAlbum(photos: selectedPhotos) 42 | shouldDismiss = true 43 | } catch { 44 | print(error) 45 | } 46 | } 47 | 48 | func onCloseButtonTapped() { 49 | shouldDismiss = true 50 | } 51 | 52 | func onAutoSelectButtonTapped() async { 53 | do { 54 | try await withThrowingTaskGroup(of: Void.self) { group in 55 | for photoGridViewModel in photoGridViewModels { 56 | group.addTask { 57 | return try await photoGridViewModel.onAutoSelectButtonTapped() 58 | } 59 | } 60 | for try await _ in group {} 61 | } 62 | } catch { 63 | print(error) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/Views/AlbumGridView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct AlbumGridView: View { 4 | private let targetSize: CGSize 5 | 6 | @StateObject private var viewModel: AlbumGridViewModel 7 | 8 | init( 9 | viewModel: AlbumGridViewModel, 10 | targetSize: CGSize 11 | ) { 12 | _viewModel = StateObject(wrappedValue: viewModel) 13 | self.targetSize = targetSize 14 | } 15 | 16 | var body: some View { 17 | Group { 18 | if let image = viewModel.image { 19 | Image(uiImage: image) 20 | .resizable() 21 | .scaledToFill() 22 | .frame( 23 | width: targetSize.width, 24 | height: targetSize.height 25 | ) 26 | } else { 27 | ProgressView() 28 | .onAppear { 29 | Task { 30 | await viewModel.onAppear() 31 | } 32 | } 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/Views/AlbumView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Photos 3 | 4 | struct AlbumView: View { 5 | @State private var isPresented = false 6 | @StateObject private var viewModel: AlbumViewModel 7 | 8 | init(viewModel: AlbumViewModel) { 9 | _viewModel = StateObject(wrappedValue: viewModel) 10 | } 11 | 12 | var body: some View { 13 | NavigationView { 14 | ZStack { 15 | AlbumCollectionView(albumPhotos: $viewModel.albumPhotos) 16 | VStack { 17 | Spacer() 18 | HStack { 19 | Spacer() 20 | AddPhotoButton { 21 | isPresented.toggle() 22 | } 23 | } 24 | } 25 | } 26 | .sheet(isPresented: $isPresented, onDismiss: { 27 | Task { 28 | await viewModel.onPhotoPickerViewDismissed() 29 | } 30 | }, content: { 31 | PhotoPickerView(viewModel: PhotoPickerViewModel()) 32 | }) 33 | .navigationTitle("Album") 34 | } 35 | .navigationViewStyle(.stack) 36 | .onAppear { 37 | Task { 38 | await viewModel.onAppear() 39 | } 40 | } 41 | } 42 | } 43 | 44 | private struct AlbumCollectionView: View { 45 | private let gridItemLayout = [ 46 | GridItem(.flexible()), 47 | GridItem(.flexible()), 48 | GridItem(.flexible()) 49 | ] 50 | 51 | @Binding var albumPhotos: [AlbumPhoto] 52 | 53 | var body: some View { 54 | ScrollView { 55 | LazyVGrid(columns: gridItemLayout) { 56 | ForEach(albumPhotos) { albumPhoto in 57 | GeometryReader { proxy in 58 | AlbumGridView( 59 | viewModel: AlbumGridViewModel(albumPhoto: albumPhoto), 60 | targetSize: CGSize( 61 | width: proxy.size.width, 62 | height: proxy.size.width 63 | ) 64 | ) 65 | } 66 | .clipped() 67 | .aspectRatio(1, contentMode: .fit) 68 | } 69 | } 70 | } 71 | } 72 | } 73 | 74 | private struct AddPhotoButton: View { 75 | var onTapped: (() -> Void) 76 | 77 | var body: some View { 78 | Button(action: { 79 | onTapped() 80 | }, label: { 81 | Text("+") 82 | .font(.system(.largeTitle)) 83 | .foregroundColor(Color.white) 84 | .padding(.bottom, 4) 85 | }) 86 | .frame(width: 60, height: 60) 87 | .background(Color.blue) 88 | .cornerRadius(30) 89 | .padding() 90 | .shadow( 91 | color: Color.black.opacity(0.3), 92 | radius: 4, 93 | x: 4, 94 | y: 4 95 | ) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/Views/PhotoGridView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Photos 3 | 4 | struct PhotoGridView: View { 5 | private let gridSize: CGSize 6 | 7 | @StateObject private var viewModel: PhotoGridViewModel 8 | 9 | init( 10 | viewModel: PhotoGridViewModel, 11 | gridSize: CGSize 12 | ) { 13 | _viewModel = StateObject(wrappedValue: viewModel) 14 | self.gridSize = gridSize 15 | } 16 | 17 | var body: some View { 18 | Group { 19 | if let image = viewModel.image { 20 | Image(uiImage: image) 21 | .resizable() 22 | .scaledToFill() 23 | .frame( 24 | width: gridSize.width, 25 | height: gridSize.height 26 | ) 27 | .overlay( 28 | Image( 29 | systemName: viewModel.isSelected 30 | ? "checkmark.circle.fill" 31 | : "circle" 32 | ) 33 | .foregroundColor( 34 | viewModel.isSelected 35 | ? Color.blue 36 | : Color.gray 37 | ), 38 | alignment: .topTrailing 39 | ) 40 | .onTapGesture { 41 | viewModel.onPhotoGridTapped() 42 | } 43 | } else { 44 | ProgressView() 45 | .onAppear { 46 | Task { 47 | await viewModel.onAppear() 48 | } 49 | } 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /PhotoSelectionClassifier/Views/PhotoPickerView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct PhotoPickerView: View { 4 | @Environment(\.dismiss) var dismiss 5 | @StateObject private var viewModel: PhotoPickerViewModel 6 | 7 | init(viewModel: PhotoPickerViewModel) { 8 | _viewModel = StateObject(wrappedValue: viewModel) 9 | } 10 | 11 | var body: some View { 12 | NavigationView { 13 | VStack { 14 | PhotoCollectionView(photoGridViewModels: $viewModel.photoGridViewModels) 15 | Spacer() 16 | ShareButton { 17 | Task { 18 | await viewModel.onShareButtonTapped() 19 | } 20 | } 21 | } 22 | .navigationBarItems( 23 | leading: Button(action: { 24 | viewModel.onCloseButtonTapped() 25 | }) { 26 | Image(systemName: "xmark") 27 | }, 28 | trailing: Group { 29 | if viewModel.showsAutoSelectButton { 30 | Button(action: { 31 | Task { 32 | await viewModel.onAutoSelectButtonTapped() 33 | } 34 | }) { 35 | Text("Auto select") 36 | } 37 | } else { 38 | EmptyView() 39 | } 40 | } 41 | ) 42 | .navigationTitle("Choose photos") 43 | .navigationBarTitleDisplayMode(.inline) 44 | } 45 | .onAppear { 46 | Task { 47 | await viewModel.onAppear() 48 | } 49 | } 50 | .onReceive(viewModel.$shouldDismiss) { shouldDismiss in 51 | if shouldDismiss { dismiss() } 52 | } 53 | } 54 | } 55 | 56 | private struct PhotoCollectionView: View { 57 | private let gridItemLayout = [ 58 | GridItem(.flexible()), 59 | GridItem(.flexible()), 60 | GridItem(.flexible()) 61 | ] 62 | 63 | @Binding var photoGridViewModels: [PhotoGridViewModel] 64 | 65 | var body: some View { 66 | ScrollView { 67 | LazyVGrid(columns: gridItemLayout) { 68 | ForEach(photoGridViewModels) { viewModel in 69 | GeometryReader { proxy in 70 | PhotoGridView( 71 | viewModel: viewModel, 72 | gridSize: CGSize( 73 | width: proxy.size.width, 74 | height: proxy.size.width 75 | ) 76 | ) 77 | } 78 | .clipped() 79 | .aspectRatio(1, contentMode: .fit) 80 | } 81 | } 82 | } 83 | } 84 | } 85 | 86 | private struct ShareButton: View { 87 | var onTapped: (() -> Void) 88 | 89 | var body: some View { 90 | Button(action: { 91 | onTapped() 92 | }, label: { 93 | Text("Share") 94 | .fontWeight(.bold) 95 | .frame(maxWidth: .infinity) 96 | .foregroundColor(Color.white) 97 | .padding(16) 98 | }) 99 | .background(Color.blue) 100 | .cornerRadius(8) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PhotoSelectionClassifier 2 | This is a sample app to create a photo selection classifier using CreateML on an iOS Device. 3 | 4 | ## Demo 5 | In the demo video below, we are selecting only hamburger photos and share to album. 6 | 7 | As a result, a trained photo selection classifier auto-selects hamburger photos. 8 | 9 | (photos quoted from: https://unsplash.com/) 10 | 11 | ![PhotoSelectionClassifierDemo](https://user-images.githubusercontent.com/8536870/128665017-0629cc59-cf17-4447-afcd-11d8cbbd0303.gif) 12 | 13 | ## Requirements 14 | - Xcode 13 beta 4 15 | - Swift 5.0+ 16 | - iOS 15.0+ 17 | 18 | ### Important 19 | **It can only be built on an actual device (not simulator) because of CreateML framework.** 20 | 21 | ## Sequence Diagram 22 | 23 | ![image](https://user-images.githubusercontent.com/8536870/128665305-4926e156-feb1-4f4e-95f5-295e8606eec7.png) 24 | 25 | ## References 26 | - https://developer.apple.com/videos/play/wwdc2021/10037 27 | - https://developer.apple.com/videos/play/wwdc2020/10156 28 | - https://developer.apple.com/documentation/createml/creating_an_image_classifier_model 29 | - https://developer.apple.com/documentation/coreml/core_ml_api/downloading_and_compiling_a_model_on_the_user_s_device 30 | - https://developer.apple.com/documentation/vision/classifying_images_with_vision_and_core_ml 31 | --------------------------------------------------------------------------------