├── MosaicMaker ├── host.json ├── proxies.json ├── local.settings.json ├── MosaicMakerFunction.csproj ├── Utilities.cs ├── DownloadImages.cs └── MosaicMaker.cs ├── images ├── custom-vision-keys.png └── orlando-eye-both.jpg ├── Client ├── package.json └── index.html ├── LICENSE ├── MosaicMaker.sln ├── setup.py ├── .gitignore └── readme.md /MosaicMaker/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "queues": {"maxPollingInterval": 500} 3 | } -------------------------------------------------------------------------------- /images/custom-vision-keys.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/options/functions-dotnet-photo-mosaic/master/images/custom-vision-keys.png -------------------------------------------------------------------------------- /images/orlando-eye-both.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/options/functions-dotnet-photo-mosaic/master/images/orlando-eye-both.jpg -------------------------------------------------------------------------------- /Client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "scripts": { 4 | "start": "live-server", 5 | "test": "echo \"Error: no test specified\" && exit 1" 6 | }, 7 | "devDependencies": { 8 | "live-server":"1.2.0" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /MosaicMaker/proxies.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/proxies", 3 | "proxies": { 4 | "root": { 5 | "matchCondition": { 6 | "route": "/" 7 | }, 8 | "backendUri": "%STORAGE_URL%content/photo-mosaic-index.html" 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /MosaicMaker/local.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "IsEncrypted": false, 3 | "Host": { 4 | "LocalHttpPort": 7071, 5 | "CORS": "*" 6 | }, 7 | "Values": { 8 | "AzureWebJobsStorage": "", 9 | "SearchAPIKey": "", 10 | "MosaicTileWidth": "20", 11 | "MosaicTileHeight": "20", 12 | "MicrosoftVisionApiKey": "", 13 | "PredictionApiUrl": "", 14 | "PredictionApiKey": "", 15 | "input-container": "input-images", 16 | "output-container": "mosaic-output", 17 | "tile-image-container": "tile-images", 18 | "generate-mosaic": "generate-mosaic", 19 | "SITEURL": "http://localhost:7071", 20 | "STORAGE_URL": "", 21 | "CONTAINER_SAS": "", 22 | "APPINSIGHTS_INSTRUMENTATIONKEY": "" 23 | } 24 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Donna Malayeri 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MosaicMaker.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26730.12 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MosaicMakerFunction", "MosaicMaker\MosaicMakerFunction.csproj", "{1C3911E4-77A5-43FC-9BC0-0734138F2089}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|x86 = Debug|x86 11 | Release|x86 = Release|x86 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {1C3911E4-77A5-43FC-9BC0-0734138F2089}.Debug|x86.ActiveCfg = Debug|Any CPU 15 | {1C3911E4-77A5-43FC-9BC0-0734138F2089}.Debug|x86.Build.0 = Debug|Any CPU 16 | {1C3911E4-77A5-43FC-9BC0-0734138F2089}.Release|x86.ActiveCfg = Release|Any CPU 17 | {1C3911E4-77A5-43FC-9BC0-0734138F2089}.Release|x86.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {D0D87168-3C0C-4B7E-9B33-6D2390E8EAF3} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /MosaicMaker/MosaicMakerFunction.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net461 4 | x86 5 | win7-x86 6 | MosaicMaker 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | PreserveNewest 21 | 22 | 23 | PreserveNewest 24 | 25 | 26 | PreserveNewest 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /MosaicMaker/Utilities.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.ApplicationInsights; 2 | using Microsoft.ApplicationInsights.Extensibility; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | 9 | namespace MosaicMaker 10 | { 11 | public class Utilities 12 | { 13 | /// 14 | /// Computes a stable non-cryptographic hash 15 | /// 16 | /// The string to use for computation 17 | /// A stable, non-cryptographic, hash 18 | internal static int GetStableHash(string value) 19 | { 20 | if (value == null) { 21 | throw new ArgumentNullException(nameof(value)); 22 | } 23 | 24 | unchecked { 25 | int hash = 23; 26 | foreach (char c in value) { 27 | hash = (hash * 31) + c; 28 | } 29 | return hash; 30 | } 31 | } 32 | 33 | 34 | public static void EmitCustomTelemetry(bool customVisionMatch, string imageKeyword) 35 | { 36 | var key = Environment.GetEnvironmentVariable("APPINSIGHTS_INSTRUMENTATIONKEY", EnvironmentVariableTarget.Process); 37 | if (key == null) { 38 | return; 39 | } 40 | 41 | TelemetryConfiguration.Active.InstrumentationKey = key; 42 | var telemetry = new TelemetryClient(); 43 | 44 | telemetry.Context.Operation.Name = "AnalyzeImage"; 45 | 46 | var properties = new Dictionary() { 47 | { "ImageKeyword", imageKeyword } 48 | }; 49 | 50 | telemetry.TrackMetric("CustomVisionMatch", customVisionMatch ? 1 : 0, properties); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | import fileinput, re, sys, getopt, subprocess 3 | 4 | settingsFilename = "MosaicMaker/local.settings.json" 5 | containerContent = "content" 6 | indexHtmlFile = "Client/index.html" 7 | 8 | def runCommand(str): 9 | return subprocess.run(str, stdout=subprocess.PIPE, shell=True).stdout.decode('utf-8').rstrip() 10 | 11 | def str2bool(v): 12 | return v.lower() in ("yes", "true", "t", "1") 13 | 14 | if len(sys.argv) < 2: 15 | print("Usage: {} ".format(sys.argv[0])) 16 | sys.exit() 17 | 18 | storageName = sys.argv[1] # storage account name 19 | resourceGroup = sys.argv[2] # storage resource group 20 | 21 | containerInput = "input-images" 22 | containerOutput = "mosaic-output" 23 | containerTiles = "tile-images" 24 | 25 | # Retrieve the Storage Account connection string 26 | connstr = runCommand('az storage account show-connection-string --name {} --resource-group {} --query connectionString --output tsv'.format(storageName, resourceGroup)) 27 | 28 | # get account URL 29 | accountUrl = \ 30 | runCommand('az storage account show --name {} -g {} --output tsv --query "{{primaryEndpoints:primaryEndpoints}}.primaryEndpoints.blob"'.format(storageName, resourceGroup)) 31 | 32 | # create containers 33 | runCommand('az storage container create --connection-string "{}" --name {}'.format(connstr, containerInput)) 34 | runCommand('az storage container create --connection-string "{}" --name {}'.format(connstr, containerOutput)) 35 | runCommand('az storage container create --connection-string "{}" --name {}'.format(connstr, containerTiles)) 36 | runCommand('az storage container create --connection-string "{}" --name {}'.format(connstr, containerContent)) 37 | 38 | # get SAS token for input container 39 | sasToken = runCommand('az storage container generate-sas --connection-string "{}" --name {} --permissions lrw --expiry 2018-01-01 -o tsv'.format(connstr, containerInput)) 40 | 41 | # set permissions on output and content containers 42 | runCommand('az storage container set-permission --connection-string "{}" --public-access blob -n {}'.format(connstr, containerOutput)) 43 | runCommand('az storage container set-permission --connection-string "{}" --public-access blob -n {}'.format(connstr, containerContent)) 44 | 45 | # upload index.html to storage 46 | runCommand('az storage blob upload --connection-string "{}" --container-name {} -f {} -n photo-mosaic-index.html --content-type "text/html"'.format(connstr, containerContent, indexHtmlFile)) 47 | 48 | # set CORS on blobs 49 | runCommand('az storage cors add --connection-string "{}" --origins "*" --methods GET PUT OPTIONS --allowed-headers "*" --exposed-headers "*" --max-age 200 --services b'.format(connstr)) 50 | 51 | # new settings values 52 | settingAzureWebJobsStorage = '"AzureWebJobsStorage": "{}"'.format(connstr) 53 | settingStorageUrl = '"STORAGE_URL": "{}"'.format(accountUrl) 54 | settingContainerSas = '"CONTAINER_SAS": "?{}"'.format(sasToken) 55 | 56 | # write out new settings values 57 | print(settingAzureWebJobsStorage) 58 | print(settingStorageUrl) 59 | print(settingContainerSas) 60 | 61 | # write changes to file 62 | with open(settingsFilename, 'r') as file: 63 | filedata = file.read() 64 | 65 | filedata = filedata.replace('"AzureWebJobsStorage": ""', settingAzureWebJobsStorage) \ 66 | .replace('"STORAGE_URL": ""', settingStorageUrl) \ 67 | .replace('"CONTAINER_SAS": ""', settingContainerSas) 68 | 69 | with open(settingsFilename, 'w') as file: 70 | file.write(filedata) 71 | 72 | print("Setup successful!") -------------------------------------------------------------------------------- /MosaicMaker/DownloadImages.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Net; 3 | using System.Net.Http; 4 | using System.Threading.Tasks; 5 | using Microsoft.Azure.WebJobs; 6 | using Microsoft.Azure.WebJobs.Extensions.Http; 7 | using Microsoft.Azure.WebJobs.Host; 8 | using System; 9 | using System.Collections.Generic; 10 | using Newtonsoft.Json; 11 | using Newtonsoft.Json.Linq; 12 | using System.Web; 13 | using Microsoft.WindowsAzure.Storage.Blob; 14 | using System.Diagnostics; 15 | 16 | namespace MosaicMaker 17 | { 18 | public static class DownloadImages 19 | { 20 | private const string SubscriptionKeyHeader = "Ocp-Apim-Subscription-Key"; 21 | private const string SearchApiKeyName = "SearchAPIKey"; 22 | private const string BingSearchUri = "https://api.cognitive.microsoft.com/bing/v5.0/images/search"; 23 | 24 | public static async Task> GetImageResultsAsync(string query, TraceWriter log) 25 | { 26 | var result = new List(); 27 | 28 | try { 29 | var httpClient = new HttpClient(); 30 | 31 | var builder = new UriBuilder(BingSearchUri); 32 | var queryParams = HttpUtility.ParseQueryString(string.Empty); 33 | queryParams["q"] = query.ToString(); 34 | queryParams["count"] = "100"; 35 | 36 | builder.Query = queryParams.ToString(); 37 | 38 | var request = new HttpRequestMessage() { 39 | RequestUri = builder.Uri, 40 | Method = HttpMethod.Get 41 | }; 42 | 43 | var apiKey = Environment.GetEnvironmentVariable(SearchApiKeyName); 44 | request.Headers.Add(SubscriptionKeyHeader, apiKey); 45 | request.Headers.Add("User-Agent", "Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 822)"); 46 | 47 | var response = await httpClient.SendAsync(request); 48 | 49 | if (response.IsSuccessStatusCode) { 50 | var resultString = await response.Content.ReadAsStringAsync(); 51 | var resultObject = JObject.Parse(resultString); 52 | 53 | if (resultObject == null) { 54 | log.Info("ERROR: No results from image search"); 55 | } 56 | 57 | var images = resultObject["value"]; 58 | 59 | foreach (var imageInfo in images) { 60 | result.Add(imageInfo["thumbnailUrl"].ToString()); 61 | } 62 | } 63 | } 64 | catch (Exception e) { 65 | log.Info($"Exception during image search: {e.Message}"); 66 | } 67 | 68 | return result; 69 | } 70 | 71 | public static async Task GetBingImagesAsync( 72 | string queryId, List imageUrls, 73 | CloudBlobContainer outputContainer, 74 | int tileWidth, int tileHeight) 75 | { 76 | var httpClient = new HttpClient(); 77 | var dir = outputContainer.GetDirectoryReference(queryId); 78 | 79 | var cachedTileCount = dir.ListBlobs(true).Count(); 80 | 81 | if (cachedTileCount >= 100) { 82 | Trace.WriteLine($"Skipping tile download, have {cachedTileCount} images cached"); 83 | return; 84 | } 85 | 86 | foreach (var url in imageUrls) { 87 | try { 88 | var resizedUrl = $"{url}&w={tileWidth}&h={tileHeight}&c=7"; 89 | var queryString = HttpUtility.ParseQueryString(new Uri(url).Query); 90 | var imageId = queryString["id"] + ".jpg"; 91 | 92 | var blob = dir.GetBlockBlobReference(imageId); 93 | 94 | if (!await blob.ExistsAsync()) { 95 | using (var responseStream = await httpClient.GetStreamAsync(resizedUrl)) { 96 | Trace.WriteLine($"Downloading blob: {imageId}"); 97 | await blob.UploadFromStreamAsync(responseStream); 98 | } 99 | } 100 | else { 101 | Trace.WriteLine($"Skipping blob download: {imageId}"); 102 | } 103 | } 104 | catch (Exception e) { 105 | Trace.WriteLine($"Exception downloading blob: {e.Message}"); 106 | continue; 107 | } 108 | } 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.suo 8 | *.user 9 | *.userosscache 10 | *.sln.docstates 11 | 12 | # User-specific files (MonoDevelop/Xamarin Studio) 13 | *.userprefs 14 | 15 | # Build results 16 | [Dd]ebug/ 17 | [Dd]ebugPublic/ 18 | [Rr]elease/ 19 | [Rr]eleases/ 20 | x64/ 21 | x86/ 22 | bld/ 23 | [Bb]in/ 24 | [Oo]bj/ 25 | [Ll]og/ 26 | 27 | # Visual Studio 2015 cache/options directory 28 | .vs/ 29 | # Uncomment if you have tasks that create the project's static files in wwwroot 30 | #wwwroot/ 31 | 32 | # MSTest test Results 33 | [Tt]est[Rr]esult*/ 34 | [Bb]uild[Ll]og.* 35 | 36 | # NUNIT 37 | *.VisualState.xml 38 | TestResult.xml 39 | 40 | # Build Results of an ATL Project 41 | [Dd]ebugPS/ 42 | [Rr]eleasePS/ 43 | dlldata.c 44 | 45 | # Benchmark Results 46 | BenchmarkDotNet.Artifacts/ 47 | 48 | # .NET Core 49 | project.lock.json 50 | project.fragment.lock.json 51 | artifacts/ 52 | **/Properties/launchSettings.json 53 | 54 | *_i.c 55 | *_p.c 56 | *_i.h 57 | *.ilk 58 | *.meta 59 | *.obj 60 | *.pch 61 | *.pdb 62 | *.pgc 63 | *.pgd 64 | *.rsp 65 | *.sbr 66 | *.tlb 67 | *.tli 68 | *.tlh 69 | *.tmp 70 | *.tmp_proj 71 | *.log 72 | *.vspscc 73 | *.vssscc 74 | .builds 75 | *.pidb 76 | *.svclog 77 | *.scc 78 | 79 | # Chutzpah Test files 80 | _Chutzpah* 81 | 82 | # Visual C++ cache files 83 | ipch/ 84 | *.aps 85 | *.ncb 86 | *.opendb 87 | *.opensdf 88 | *.sdf 89 | *.cachefile 90 | *.VC.db 91 | *.VC.VC.opendb 92 | 93 | # Visual Studio profiler 94 | *.psess 95 | *.vsp 96 | *.vspx 97 | *.sap 98 | 99 | # TFS 2012 Local Workspace 100 | $tf/ 101 | 102 | # Guidance Automation Toolkit 103 | *.gpState 104 | 105 | # ReSharper is a .NET coding add-in 106 | _ReSharper*/ 107 | *.[Rr]e[Ss]harper 108 | *.DotSettings.user 109 | 110 | # JustCode is a .NET coding add-in 111 | .JustCode 112 | 113 | # TeamCity is a build add-in 114 | _TeamCity* 115 | 116 | # DotCover is a Code Coverage Tool 117 | *.dotCover 118 | 119 | # AxoCover is a Code Coverage Tool 120 | .axoCover/* 121 | !.axoCover/settings.json 122 | 123 | # Visual Studio code coverage results 124 | *.coverage 125 | *.coveragexml 126 | 127 | # NCrunch 128 | _NCrunch_* 129 | .*crunch*.local.xml 130 | nCrunchTemp_* 131 | 132 | # MightyMoose 133 | *.mm.* 134 | AutoTest.Net/ 135 | 136 | # Web workbench (sass) 137 | .sass-cache/ 138 | 139 | # Installshield output folder 140 | [Ee]xpress/ 141 | 142 | # DocProject is a documentation generator add-in 143 | DocProject/buildhelp/ 144 | DocProject/Help/*.HxT 145 | DocProject/Help/*.HxC 146 | DocProject/Help/*.hhc 147 | DocProject/Help/*.hhk 148 | DocProject/Help/*.hhp 149 | DocProject/Help/Html2 150 | DocProject/Help/html 151 | 152 | # Click-Once directory 153 | publish/ 154 | 155 | # Publish Web Output 156 | *.[Pp]ublish.xml 157 | *.azurePubxml 158 | # Note: Comment the next line if you want to checkin your web deploy settings, 159 | # but database connection strings (with potential passwords) will be unencrypted 160 | *.pubxml 161 | *.publishproj 162 | 163 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 164 | # checkin your Azure Web App publish settings, but sensitive information contained 165 | # in these scripts will be unencrypted 166 | PublishScripts/ 167 | 168 | # NuGet Packages 169 | *.nupkg 170 | # The packages folder can be ignored because of Package Restore 171 | **/packages/* 172 | # except build/, which is used as an MSBuild target. 173 | !**/packages/build/ 174 | # Uncomment if necessary however generally it will be regenerated when needed 175 | #!**/packages/repositories.config 176 | # NuGet v3's project.json files produces more ignorable files 177 | *.nuget.props 178 | *.nuget.targets 179 | 180 | # Microsoft Azure Build Output 181 | csx/ 182 | *.build.csdef 183 | 184 | # Microsoft Azure Emulator 185 | ecf/ 186 | rcf/ 187 | 188 | # Windows Store app package directories and files 189 | AppPackages/ 190 | BundleArtifacts/ 191 | Package.StoreAssociation.xml 192 | _pkginfo.txt 193 | *.appx 194 | 195 | # Visual Studio cache files 196 | # files ending in .cache can be ignored 197 | *.[Cc]ache 198 | # but keep track of directories ending in .cache 199 | !*.[Cc]ache/ 200 | 201 | # Others 202 | ClientBin/ 203 | ~$* 204 | *~ 205 | *.dbmdl 206 | *.dbproj.schemaview 207 | *.jfm 208 | *.pfx 209 | *.publishsettings 210 | orleans.codegen.cs 211 | 212 | # Since there are multiple workflows, uncomment next line to ignore bower_components 213 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 214 | #bower_components/ 215 | 216 | # RIA/Silverlight projects 217 | Generated_Code/ 218 | 219 | # Backup & report files from converting an old project file 220 | # to a newer Visual Studio version. Backup files are not needed, 221 | # because we have git ;-) 222 | _UpgradeReport_Files/ 223 | Backup*/ 224 | UpgradeLog*.XML 225 | UpgradeLog*.htm 226 | 227 | # SQL Server files 228 | *.mdf 229 | *.ldf 230 | *.ndf 231 | 232 | # Business Intelligence projects 233 | *.rdl.data 234 | *.bim.layout 235 | *.bim_*.settings 236 | 237 | # Microsoft Fakes 238 | FakesAssemblies/ 239 | 240 | # GhostDoc plugin setting file 241 | *.GhostDoc.xml 242 | 243 | # Node.js Tools for Visual Studio 244 | .ntvs_analysis.dat 245 | node_modules/ 246 | 247 | # Typescript v1 declaration files 248 | typings/ 249 | 250 | # Visual Studio 6 build log 251 | *.plg 252 | 253 | # Visual Studio 6 workspace options file 254 | *.opt 255 | 256 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 257 | *.vbw 258 | 259 | # Visual Studio LightSwitch build output 260 | **/*.HTMLClient/GeneratedArtifacts 261 | **/*.DesktopClient/GeneratedArtifacts 262 | **/*.DesktopClient/ModelManifest.xml 263 | **/*.Server/GeneratedArtifacts 264 | **/*.Server/ModelManifest.xml 265 | _Pvt_Extensions 266 | 267 | # Paket dependency manager 268 | .paket/paket.exe 269 | paket-files/ 270 | 271 | # FAKE - F# Make 272 | .fake/ 273 | 274 | # JetBrains Rider 275 | .idea/ 276 | *.sln.iml 277 | 278 | # CodeRush 279 | .cr/ 280 | 281 | # Python Tools for Visual Studio (PTVS) 282 | __pycache__/ 283 | *.pyc 284 | 285 | # Cake - Uncomment if you are using it 286 | # tools/** 287 | # !tools/packages.config 288 | 289 | # Tabs Studio 290 | *.tss 291 | 292 | # Telerik's JustMock configuration file 293 | *.jmconfig 294 | 295 | # BizTalk build output 296 | *.btp.cs 297 | *.btm.cs 298 | *.odx.cs 299 | *.xsd.cs 300 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | --- 2 | services: functions 3 | platforms: dotnet 4 | author: lindydonna 5 | --- 6 | 7 | # Azure Functions Photo Mosaic Generator 8 | 9 | Use Azure Functions and [Microsoft Cognitive Services Custom Vision Service](https://azure.microsoft.com/en-us/services/cognitive-services/custom-vision-service/) to generate a photo mosaic from an input image. 10 | 11 | For example, you can train your model with Orlando landmarks, such as the Orlando Eye. Custom Vision will recognize an image of the Orlando Eye, and the function will create a photo mosaic composed of Bing image search results for "Orlando Eye." See example below. 12 | 13 | ![Orlando Eye Mosaic](images/orlando-eye-both.jpg) 14 | 15 | ## Branches 16 | 17 | - Use the `master` branch if you're on Windows 18 | - Use the `core` branch if you're on a Mac. 19 | 20 | ## Prerequisites 21 | 22 | 1. Visual Studio, either: 23 | - Visual Studio 2017 Update 3 with the Azure workload installed (Windows) 24 | - Visual Studio Code with the [C# extension](https://code.visualstudio.com/docs/languages/csharp) (Mac/Linux) 25 | 26 | 1. If running on a Mac/Linux, [.NET Core 2.0](https://www.microsoft.com/net/core#macos) 27 | 28 | 1. If running on a Mac/Linx, install [azure\-functions\-core\-tools](https://www.npmjs.com/package/azure-functions-core-tools) from npm 29 | 30 | 1. Azure Storage Account 31 | 32 | 1. [Azure CLI 2.0](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli?view=azure-cli-latest) 33 | 34 | ## 1. Create API keys 35 | 36 | 1. Create a Bing Search API key: 37 | 38 | - In the Azure portal, click **+ New** and search for **Bing Search APIs**. 39 | - Enter the required information in the Create blade. You may use the lowest service tier of **S1** for this module. 40 | 41 | 1. (Optional) Create a Computer Vision API key. The function will fall back to the regular Computer Vision API if there isn't a match with images that have been trained in the Custom Vision Service. If you plan to test only with images that will match custom vision, you can skip this step. 42 | 43 | To create a Computer Vision API key: 44 | 45 | - In the Azure portal, click **+ New** and search for **Bing Search APIs**. 46 | - Enter the required information in the Create blade. You may use the free tier **F0** for this module. 47 | 48 | ## 2. Set up Custom Vision Service project 49 | 50 | 1. Go to https://www.customvision.ai/ 51 | 52 | 1. Sign in with a Microsoft account 53 | 54 | 1. Create a new project and select the image type, such as "Landmarks" 55 | 56 | 1. Add images and tag them. You can use a Chrome extension such as [Bulk Image Downloader](http://www.talkapps.org/bulk-image-downloader) to download Google or Bing images of landmarks. 57 | 58 | 1. Once you have added several landmarks, click the **Train** button on the upper right. Make sure you have at least 2 tags and 5 images for each tag. 59 | 60 | 1. (Optional) Test image recognition using the **Test** tab. 61 | 62 | 1. Click on the **Performance** tab. If you have more than one iteration, choose the latest iteration and click **Make default**. 63 | 64 | 65 | ## 3. Configure the photo mosaic project 66 | 67 | 68 | ### Run the install script 69 | 70 | There's a Python setup script [setup.py](setup.py) that will set up your storage account keys. It uses the Azure CLI 2.0 to automate the storage account setup. Run the following commands: 71 | 72 | ``` 73 | az login 74 | python setup.py storage-account resource-group 75 | ``` 76 | 77 | If you get python errors, make sure you've installed Python 3 and run the command `python3` instead (see [Installing Python 3 on Mac OS X](http://docs.python-guide.org/en/latest/starting/install3/osx/)). 78 | 79 | This will modify the file [local.settings.json](MosaicMaker/local.settings.json). 80 | 81 | Alternatively, you can run the script from the Azure Cloud Shell in the Azure Portal. Just run `python` and paste the script. The script prints out settings values that you can use to manually modify `local.settings.json`. 82 | 83 | ### Edit local.settings.json 84 | 85 | 1. If using Visual Studio, open **MosaicMaker.sln**. On a Mac, open the **photo-mosaic** folder in VS Code. 86 | 87 | 1. Open the file **MosaicMaker/local.settings.json** 88 | 89 | 1. In the [Custom Vision portal](https://www.customvision.ai/), get the URL for your prediction service. Select **Prediction URL** and copy the second URL in the dialog box, under the section "**If you have an image file**". It will have the form `https://southcentralus.api.cognitive.microsoft.com/customvision/v1.0/Prediction//image`. Paste this value for the key `PredictionApiUrl` in **local.settings.json**. 90 | 91 | 1. In the Custom Vision portal, select the settings gear in the upper right. Copy the value of **Prediction Key** for the key `PredictionApiKey` in **local.settings.json**. 92 | 93 | ![Prediction API key](images/custom-vision-keys.png) 94 | 95 | 1. In the Azure portal, select your Bing Search APIs instance. Select the **Keys** menu item and copy the value of **KEY 1**. Paste the value for the key `SearchAPIKey`in **local.settings.json**. 96 | 97 | 1. (Optional) Photo mosaic will fall back to the regular vision service if there is not a match with custom vision. Paste your key for your Cognitive Services Vision Service as the value for `MicrosoftVisionApiKey` in **local.settings.json**. 98 | 99 | ### Summary of App Settings 100 | 101 | | Key | Description | 102 | |----- | ------| 103 | | AzureWebJobsStorage | Storage account connection string. | 104 | | SearchAPIKey | Key for [Bing Search API](https://azure.microsoft.com/en-us/services/cognitive-services/bing-web-search-api/). | 105 | | MicrosoftVisionApiKey | Key for [Computer Vision Service](https://azure.microsoft.com/en-us/services/cognitive-services/computer-vision/). | 106 | | PredictionApiUrl | Endpoint for [Cognitive Services Custom Vision Service](https://azure.microsoft.com/en-us/services/cognitive-services/custom-vision-service/). It should end with "image". | 107 | | PredictionApiKey | Prediction key for [Cognitive Services Custom Vision Service](https://azure.microsoft.com/en-us/services/cognitive-services/custom-vision-service/). | 108 | | generate-mosaic | Name of Storage queue for to trigger mosaic generation. Default value is "generate-mosaic". | 109 | | input-container | Name of Storage container for input images. Default value is "input-images". | 110 | | output-container | Name of Storage container for output images. Default value is "mosaic-output". | 111 | | tile-image-container | Name of Storage container for tile images. Default value is "tile-images". | 112 | | SITEURL | Set to `http://localhost:7072` locally. Not required on Azure. | 113 | | STORAGE_URL | URL of storage account, in the form `https://accountname.blob.core.windows.net/` | 114 | | CONTAINER_SAS | SAS token for uploading to input-container. Include the "?" prefix. | 115 | | APPINSIGHTS_INSTRUMENTATIONKEY | (optional) Application Insights instrumentation key. | 116 | | MosaicTileWidth | Default width of each mosaic tile. | 117 | | MosaicTileHeight | Default height of each mosaic tile. | 118 | 119 | If you want to set these values in Azure, you can set them in **local.settings.json** and use the Azure Functions Core Tools to publish to Azure. 120 | 121 | ``` 122 | func azure functionapp publish function-app-name --publish-app-settings 123 | ``` 124 | 125 | ## 4. Load Tile Images 126 | 127 | When the function app creates a mosaic, it needs source images to compose the mosaic. The **tile-image-container** referred to in App Settings (defaulted to **tile-images**) is the container that the function will look for images to use. Running the **setup.py** script above generated the containers for you, but you'll need to load images that container. Using the portal, [Azure Storage Explorer](https://azure.microsoft.com/en-us/features/storage-explorer/), or any other method of uploading blobs, upload images into the **tile-image-container** inside of your storage account. You can reuse the landmark images you downloaded earlier if desired. 128 | 129 | ## 5. Run the project 130 | 131 | 1. Compile and run: 132 | 133 | - If using Visual Studio, just press F5 to compile and run **PhotoMosaic.sln**. 134 | 135 | - If using VS Code on a Mac, the build task will run `dotnet build`. Then, navigate to the output folder and run the Functions core tools: 136 | 137 | ``` 138 | cd photo-mosaic/MosaicMaker/bin/Debug/netstandard2.0/osx 139 | func host start 140 | ``` 141 | 142 | You should see output similar to the following: 143 | 144 | ``` 145 | Http Functions: 146 | 147 | RequestMosaic: http://localhost:7072/api/RequestMosaic 148 | 149 | Settings: http://localhost:7072/api/Settings 150 | 151 | [10/4/2017 10:24:20 PM] Host lock lease acquired by instance ID '000000000000000000000000C9A597BE'. 152 | [10/4/2017 10:24:20 PM] Found the following functions: 153 | [10/4/2017 10:24:20 PM] MosaicMaker.MosaicBuilder.RequestImageProcessing 154 | [10/4/2017 10:24:20 PM] MosaicMaker.MosaicBuilder.Settings 155 | [10/4/2017 10:24:20 PM] MosaicMaker.MosaicBuilder.CreateMosaicAsync 156 | [10/4/2017 10:24:20 PM] 157 | [10/4/2017 10:24:20 PM] Job host started 158 | Debugger listening on [::]:5858 159 | ``` 160 | 161 | 2. To test that the host is up and running, navigate to [http://localhost:7072/api/Settings](http://localhost:7072/api/Settings). 162 | 163 | ## Run 164 | 165 | - To run using the provided SPA, open in a command prompt and navigate to the `Client` directory. 166 | 167 | - Run `npm install` 168 | - Run `npm start`. This will launch a webpage at `http://127.0.0.1:8080/`. 169 | 170 | - To run manually, send an HTTP request using Postman or CURL: 171 | 172 | `POST http://localhost:7072/api/RequestMosaic` 173 | 174 | Body: 175 | ```json 176 | { 177 | "InputImageUrl": "http://url.of.your.image", 178 | "ImageContentString": "optional keyword for mosaic tiles", 179 | "TilePixels": 20 180 | } 181 | ``` 182 | -------------------------------------------------------------------------------- /MosaicMaker/MosaicMaker.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Azure.WebJobs; 2 | using Microsoft.Azure.WebJobs.Extensions.Http; 3 | using Microsoft.Azure.WebJobs.Host; 4 | using Microsoft.ProjectOxford.Vision; 5 | using Microsoft.WindowsAzure.Storage.Blob; 6 | using Newtonsoft.Json.Linq; 7 | using SkiaSharp; 8 | using System; 9 | using System.Collections.Generic; 10 | using System.Diagnostics; 11 | using System.IO; 12 | using System.Linq; 13 | using System.Net; 14 | using System.Net.Http; 15 | using System.Net.Http.Headers; 16 | using System.Threading.Tasks; 17 | 18 | namespace MosaicMaker 19 | { 20 | public static class MosaicBuilder 21 | { 22 | #region Member variables 23 | 24 | private static readonly string VisionServiceApiKey = Environment.GetEnvironmentVariable("MicrosoftVisionApiKey"); 25 | private static readonly string ImagePredictionKey = Environment.GetEnvironmentVariable("PredictionApiKey"); 26 | 27 | public static int TileHeight { get; set; } 28 | public static int TileWidth { get; set; } 29 | public static int DitheringRadius { get; set; } 30 | public static int ScaleMultiplier { get; set; } 31 | 32 | #endregion 33 | 34 | [FunctionName("RequestMosaic")] 35 | public static HttpResponseMessage RequestImageProcessing( 36 | [HttpTrigger(AuthorizationLevel.Anonymous, new string[] { "POST" })] MosaicRequest input, 37 | [Queue("%generate-mosaic%")] out MosaicRequest queueOutput, 38 | TraceWriter log) 39 | { 40 | if (string.IsNullOrEmpty(input.OutputFilename)) 41 | input.OutputFilename = $"{Guid.NewGuid()}.jpg"; 42 | 43 | queueOutput = input; 44 | 45 | // get output URL 46 | var storageURL = Environment.GetEnvironmentVariable("STORAGE_URL"); 47 | var outputContainerName = Environment.GetEnvironmentVariable("output-container"); 48 | var location = $"{storageURL}{outputContainerName}/{input.OutputFilename}"; 49 | log.Info($"\n\nOutput location:\n{location}\n\n"); 50 | 51 | var response = new HttpResponseMessage(HttpStatusCode.Accepted); 52 | response.Content = new StringContent(location); 53 | response.Headers.Location = new Uri(location); 54 | 55 | return response; 56 | } 57 | 58 | [FunctionName("Settings")] 59 | public static SettingsMessage Settings( 60 | [HttpTrigger(AuthorizationLevel.Anonymous, new string[] { "GET" })] string input, 61 | TraceWriter log) 62 | { 63 | string stage = (Environment.GetEnvironmentVariable("STAGE") == null) ? "LOCAL" : Environment.GetEnvironmentVariable("STAGE"); 64 | return new SettingsMessage() { 65 | Stage = stage, 66 | SiteURL = Environment.GetEnvironmentVariable("SITEURL"), 67 | StorageURL = Environment.GetEnvironmentVariable("STORAGE_URL"), 68 | ContainerSAS = Environment.GetEnvironmentVariable("CONTAINER_SAS"), 69 | InputContainerName = Environment.GetEnvironmentVariable("input-container"), 70 | OutputContainerName = Environment.GetEnvironmentVariable("output-container") 71 | }; 72 | } 73 | 74 | [FunctionName("CreateMosaic")] 75 | public static async Task CreateMosaicAsync( 76 | [QueueTrigger("%generate-mosaic%")] MosaicRequest mosaicRequest, 77 | [Blob("%tile-image-container%")] CloudBlobContainer tileContainer, 78 | [Blob("%output-container%/{OutputFilename}", FileAccess.Write)] Stream outputStream, 79 | TraceWriter log) 80 | { 81 | var imageKeyword = mosaicRequest.ImageContentString; 82 | var sourceImage = await DownloadFileAsync(mosaicRequest.InputImageUrl); 83 | 84 | // fall back to regular vision service if PredictionApiUrl is empty, 85 | // or if Custom Vision does not have high confidence 86 | bool noCustomImageSearch = false; 87 | 88 | if (String.IsNullOrEmpty(mosaicRequest.ImageContentString)) { // no keyword provided, use image recognition 89 | 90 | string predictionUrl = Environment.GetEnvironmentVariable("PredictionApiUrl"); 91 | 92 | if (String.IsNullOrEmpty(predictionUrl)) { // if no Custom Vision API key was provided, skip it 93 | noCustomImageSearch = true; 94 | } 95 | 96 | try { 97 | imageKeyword = await PredictImageAsync(predictionUrl, sourceImage, log); 98 | noCustomImageSearch = String.IsNullOrEmpty(imageKeyword); 99 | } 100 | catch (Exception e) { 101 | log.Info($"Custom image failed: {e.Message}"); 102 | noCustomImageSearch = true; // on exception, use regular Vision Service 103 | } 104 | 105 | if (noCustomImageSearch) { 106 | log.Info("Falling back to Vision Service"); 107 | 108 | sourceImage.Seek(0, SeekOrigin.Begin); 109 | imageKeyword = await AnalyzeImageAsync(sourceImage); 110 | } 111 | } 112 | else { 113 | if (imageKeyword.EndsWith(" ")) { 114 | imageKeyword = imageKeyword.Substring(0, imageKeyword.IndexOf(" ")); 115 | } 116 | } 117 | 118 | log.Info($"\n\nImage analysis: {imageKeyword}\n"); 119 | 120 | var queryDirectory = Utilities.GetStableHash(imageKeyword).ToString(); 121 | log.Info($"Query hash: {queryDirectory}"); 122 | 123 | var imageUrls = await DownloadImages.GetImageResultsAsync(imageKeyword, log); 124 | await DownloadImages.GetBingImagesAsync( 125 | queryDirectory, imageUrls, tileContainer, TileWidth, TileHeight); 126 | 127 | await GenerateMosaicFromTilesAsync( 128 | sourceImage, tileContainer, queryDirectory, 129 | outputStream, 130 | mosaicRequest.TilePixels, 131 | log); 132 | 133 | Utilities.EmitCustomTelemetry(!noCustomImageSearch, imageKeyword); 134 | } 135 | 136 | private static async Task DownloadFileAsync(string inputImageUrl) 137 | { 138 | var client = new HttpClient(); 139 | 140 | try { 141 | var bytes = await client.GetByteArrayAsync(inputImageUrl); 142 | return new MemoryStream(bytes); 143 | 144 | } 145 | catch (Exception) { 146 | return null; 147 | } 148 | } 149 | 150 | public class MosaicRequest 151 | { 152 | public string InputImageUrl { get; set; } 153 | public string OutputFilename { get; set; } 154 | public string ImageContentString { get; set; } // if null or empty, use image recognition on the input image 155 | public int TilePixels { get; set; } // override default value in app settings 156 | } 157 | 158 | #region Helpers 159 | private static async Task AnalyzeImageAsync(Stream image) 160 | { 161 | var client = new VisionServiceClient(VisionServiceApiKey); 162 | var result = await client.AnalyzeImageAsync(image, new VisualFeature[] { VisualFeature.Description }); 163 | 164 | return result.Description.Captions.FirstOrDefault().Text; 165 | } 166 | 167 | static byte[] GetImageAsByteArray(Stream imageStream) 168 | { 169 | BinaryReader binaryReader = new BinaryReader(imageStream); 170 | return binaryReader.ReadBytes((int)imageStream.Length); 171 | } 172 | 173 | static async Task PredictImageAsync(string predictionApiUrl, Stream imageStream, TraceWriter log) 174 | { 175 | var client = new HttpClient(); 176 | client.DefaultRequestHeaders.Add("Prediction-Key", ImagePredictionKey); 177 | 178 | HttpResponseMessage response; 179 | byte[] byteData = GetImageAsByteArray(imageStream); 180 | imageStream.Seek(0, SeekOrigin.Begin); 181 | 182 | using (var content = new ByteArrayContent(byteData)) { 183 | content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); 184 | response = await client.PostAsync(predictionApiUrl, content); 185 | 186 | var resultString = await response.Content.ReadAsStringAsync(); 187 | var resultObject = JObject.Parse(resultString); 188 | 189 | var prediction = resultObject["Predictions"].First; 190 | var probability = prediction["Probability"].ToObject(); 191 | var tag = prediction["Tag"].ToString(); 192 | 193 | log.Info($"Tag: {tag}, Probability {probability}"); 194 | 195 | return probability >= 0.1 ? tag : ""; 196 | } 197 | } 198 | 199 | public static async Task GenerateMosaicFromTilesAsync( 200 | Stream sourceImage, 201 | CloudBlobContainer tileContainer, string tileDirectory, 202 | Stream outputStream, 203 | int tilePixels, 204 | TraceWriter log) 205 | { 206 | using (var tileProvider = new QuadrantMatchingTileProvider()) { 207 | MosaicBuilder.TileHeight = int.Parse(Environment.GetEnvironmentVariable("MosaicTileWidth")); 208 | MosaicBuilder.TileWidth = int.Parse(Environment.GetEnvironmentVariable("MosaicTileHeight")); 209 | 210 | // override default tile width and height if specified 211 | if (tilePixels != 0) { 212 | MosaicBuilder.TileWidth = MosaicBuilder.TileHeight = tilePixels; 213 | } 214 | 215 | MosaicBuilder.DitheringRadius = -1; 216 | MosaicBuilder.ScaleMultiplier = 1; 217 | List tileImages = await GetTileImagesAsync(tileContainer, tileDirectory, log); 218 | 219 | tileProvider.SetSourceStream(sourceImage); 220 | tileProvider.ProcessInputImageColors(MosaicBuilder.TileWidth, MosaicBuilder.TileHeight); 221 | tileProvider.ProcessTileColors(tileImages); 222 | 223 | log.Info("Generating mosaic..."); 224 | var start = DateTime.Now; 225 | 226 | GenerateMosaic(tileProvider, sourceImage, tileImages, outputStream); 227 | 228 | log.Info($"Time to generate mosaic: {(DateTime.Now - start).TotalMilliseconds}"); 229 | } 230 | } 231 | 232 | private static async Task> GetTileImagesAsync(CloudBlobContainer tileContainer, string tileDirectory, TraceWriter log) 233 | { 234 | var cacheDir = Path.Combine(Path.GetTempPath(), "MosaicCache", tileDirectory); 235 | Directory.CreateDirectory(cacheDir); 236 | var files = new DirectoryInfo(cacheDir).GetFiles(); 237 | if (files.Length >= 50) { 238 | return files.Select(x => File.ReadAllBytes(x.FullName)).ToList(); 239 | } 240 | 241 | log.Info("Downloading tiles images from storage"); 242 | var start = DateTime.Now; 243 | 244 | var directory = tileContainer.GetDirectoryReference(""); 245 | var blobs = directory.ListBlobs(true); 246 | 247 | var tasks = blobs.OfType().Select(blob => Task.Run(() => { 248 | blob.DownloadToFile(Path.Combine(cacheDir, Guid.NewGuid().ToString()), FileMode.Create); 249 | })); 250 | 251 | await Task.WhenAll(tasks); 252 | files = new DirectoryInfo(cacheDir).GetFiles(); 253 | var result = files.Select(x => File.ReadAllBytes(x.FullName)).ToList(); 254 | 255 | log.Info($"Total time to fetch tiles {(DateTime.Now - start).TotalMilliseconds}"); 256 | 257 | return result; 258 | } 259 | 260 | public static void SaveImage(string fullPath, SKImage outImage) 261 | { 262 | var imageBytes = outImage.Encode(SKEncodedImageFormat.Jpeg, 80); 263 | using (var outStream = new FileStream(fullPath, FileMode.Create, FileAccess.Write)) { 264 | imageBytes.SaveTo(outStream); 265 | } 266 | } 267 | 268 | private static void GenerateMosaic(QuadrantMatchingTileProvider tileProvider, Stream inputStream, List tileImages, Stream outputStream) 269 | { 270 | SKBitmap[,] mosaicTileGrid; 271 | 272 | inputStream.Seek(0, SeekOrigin.Begin); 273 | 274 | using (var skStream = new SKManagedStream(inputStream)) 275 | using (var bitmap = SKBitmap.Decode(skStream)) { 276 | 277 | // use transparency for the source image overlay 278 | var srcImagePaint = new SKPaint() { Color = SKColors.White.WithAlpha(200) }; 279 | 280 | int xTileCount = bitmap.Width / MosaicBuilder.TileWidth; 281 | int yTileCount = bitmap.Height / MosaicBuilder.TileHeight; 282 | 283 | int tileCount = xTileCount * yTileCount; 284 | 285 | mosaicTileGrid = new SKBitmap[xTileCount, yTileCount]; 286 | 287 | int finalTileWidth = MosaicBuilder.TileWidth * MosaicBuilder.ScaleMultiplier; 288 | int finalTileHeight = MosaicBuilder.TileHeight * MosaicBuilder.ScaleMultiplier; 289 | int targetWidth = xTileCount * finalTileWidth; 290 | int targetHeight = yTileCount * finalTileHeight; 291 | 292 | var tileList = new List<(int, int)>(); 293 | 294 | // add coordinates for the left corner of each tile 295 | for (int x = 0; x < xTileCount; x++) { 296 | for (int y = 0; y < yTileCount; y++) { 297 | tileList.Add((x, y)); 298 | } 299 | } 300 | 301 | // create output surface 302 | var surface = SKSurface.Create(targetWidth, targetHeight, SKImageInfo.PlatformColorType, SKAlphaType.Premul); 303 | surface.Canvas.DrawColor(SKColors.White); // clear the canvas / fill with white 304 | surface.Canvas.DrawBitmap(bitmap, 0, 0, srcImagePaint); 305 | 306 | // using the Darken blend mode causes colors from the source image to come through 307 | var tilePaint = new SKPaint() { BlendMode = SKBlendMode.Darken }; 308 | surface.Canvas.SaveLayer(tilePaint); // save layer so blend mode is applied 309 | 310 | var random = new Random(); 311 | 312 | while (tileList.Count > 0) { 313 | 314 | // choose a new tile at random 315 | int nextIndex = random.Next(tileList.Count); 316 | var tileInfo = tileList[nextIndex]; 317 | tileList.RemoveAt(nextIndex); 318 | 319 | // get the tile image for this point 320 | //var exclusionList = GetExclusionList(mosaicTileGrid, tileInfo.Item1, tileInfo.Item2); 321 | var tileBitmap = tileProvider.GetImageForTile(tileInfo.Item1, tileInfo.Item2); 322 | mosaicTileGrid[tileInfo.Item1, tileInfo.Item2] = tileBitmap; 323 | 324 | // draw the tile on the surface at the coordinates 325 | SKRect tileRect = SKRect.Create(tileInfo.Item1 * TileWidth, tileInfo.Item2 * TileHeight, finalTileWidth, finalTileHeight); 326 | surface.Canvas.DrawBitmap(tileBitmap, tileRect); 327 | } 328 | 329 | surface.Canvas.Restore(); // merge layers 330 | surface.Canvas.Flush(); 331 | 332 | var imageBytes = surface.Snapshot().Encode(SKEncodedImageFormat.Jpeg, 80); 333 | imageBytes.SaveTo(outputStream); 334 | } 335 | } 336 | 337 | private static List GetExclusionList(string[,] mosaicTileGrid, int xIndex, int yIndex) 338 | { 339 | int xRadius = (MosaicBuilder.DitheringRadius != -1 ? MosaicBuilder.DitheringRadius : mosaicTileGrid.GetLength(0)); 340 | int yRadius = (MosaicBuilder.DitheringRadius != -1 ? MosaicBuilder.DitheringRadius : mosaicTileGrid.GetLength(1)); 341 | 342 | var exclusionList = new List(); 343 | 344 | // TODO: add this back. Currently requires too many input tile images 345 | 346 | //for (int x = Math.Max(0, xIndex - xRadius); x < Math.Min(mosaicTileGrid.GetLength(0), xIndex + xRadius); x++) { 347 | // for (int y = Math.Max(0, yIndex - yRadius); y < Math.Min(mosaicTileGrid.GetLength(1), yIndex + yRadius); y++) { 348 | // if (mosaicTileGrid[x, y] != null) 349 | // exclusionList.Add(mosaicTileGrid[x, y]); 350 | // } 351 | //} 352 | 353 | return exclusionList; 354 | } 355 | 356 | public class SettingsMessage 357 | { 358 | public string Stage { get; set; } 359 | public string SiteURL { get; set; } 360 | public string StorageURL { get; set; } 361 | public string ContainerSAS { get; set; } 362 | public string InputContainerName { get; set; } 363 | public string OutputContainerName { get; set; } 364 | } 365 | 366 | #endregion 367 | } 368 | 369 | public class QuadrantMatchingTileProvider : IDisposable 370 | { 371 | internal static int quadrantDivisionCount = 1; 372 | private Stream inputStream; 373 | private SKColor[,][,] inputImageRGBGrid; 374 | private readonly List<(SKBitmap, SKColor[,])> tileImageRGBGridList = new List<(SKBitmap, SKColor[,])>(); 375 | private Random random = new Random(); 376 | 377 | public void SetSourceStream(Stream inputStream) 378 | { 379 | this.inputStream = inputStream; 380 | inputStream.Seek(0, SeekOrigin.Begin); 381 | } 382 | 383 | // Preprocess the quadrants of the input image 384 | public void ProcessInputImageColors(int tileWidth, int tileHeight) 385 | { 386 | using (var skStream = new SKManagedStream(inputStream)) 387 | using (var bitmap = SKBitmap.Decode(skStream)) { 388 | 389 | int xTileCount = bitmap.Width / tileWidth; 390 | int yTileCount = bitmap.Height / tileHeight; 391 | 392 | int tileDivisionWidth = tileWidth / quadrantDivisionCount; 393 | int tileDivisionHeight = tileHeight / quadrantDivisionCount; 394 | 395 | int quadrantsCompleted = 0; 396 | int quadrantsTotal = xTileCount * yTileCount * quadrantDivisionCount * quadrantDivisionCount; 397 | inputImageRGBGrid = new SKColor[xTileCount, yTileCount][,]; 398 | 399 | //Divide the input image into separate tile sections and calculate the average pixel value for each one 400 | for (int yTileIndex = 0; yTileIndex < yTileCount; yTileIndex++) { 401 | for (int xTileIndex = 0; xTileIndex < xTileCount; xTileIndex++) { 402 | var rect = SKRectI.Create(xTileIndex * tileWidth, yTileIndex * tileHeight, tileWidth, tileHeight); 403 | inputImageRGBGrid[xTileIndex, yTileIndex] = GetAverageColorGrid(bitmap, rect); 404 | quadrantsCompleted += (quadrantDivisionCount * quadrantDivisionCount); 405 | } 406 | } 407 | } 408 | } 409 | 410 | // Convert tile images to average color 411 | public void ProcessTileColors(List tileImages) 412 | { 413 | foreach (var bytes in tileImages) { 414 | 415 | var bitmap = SKBitmap.Decode(bytes); 416 | 417 | var rect = SKRectI.Create(0, 0, bitmap.Width, bitmap.Height); 418 | tileImageRGBGridList.Add((bitmap, GetAverageColorGrid(bitmap, rect))); 419 | } 420 | } 421 | 422 | // Returns the best match image per tile area 423 | public SKBitmap GetImageForTile(int xIndex, int yIndex) 424 | { 425 | var tileDistances = new List<(double, SKBitmap)>(); 426 | 427 | foreach (var tileGrid in tileImageRGBGridList) { 428 | double distance = 0; 429 | 430 | for (int x = 0; x < quadrantDivisionCount; x++) 431 | for (int y = 0; y < quadrantDivisionCount; y++) { 432 | distance += 433 | Math.Sqrt( 434 | Math.Abs(Math.Pow(tileGrid.Item2[x, y].Red, 2) - Math.Pow(inputImageRGBGrid[xIndex, yIndex][x, y].Red, 2)) + 435 | Math.Abs(Math.Pow(tileGrid.Item2[x, y].Green, 2) - Math.Pow(inputImageRGBGrid[xIndex, yIndex][x, y].Green, 2)) + 436 | Math.Abs(Math.Pow(tileGrid.Item2[x, y].Blue, 2) - Math.Pow(inputImageRGBGrid[xIndex, yIndex][x, y].Blue, 2))); 437 | } 438 | 439 | tileDistances.Add((distance, tileGrid.Item1)); 440 | } 441 | 442 | var sorted = tileDistances 443 | //.Where(x => !excludedImageFiles.Contains(x.Item2)) // remove items from excluded list 444 | .OrderBy(item => item.Item1); // sort by best match 445 | 446 | var rand = random.Next(5); 447 | 448 | return rand < 4 ? sorted.First().Item2 : sorted.ElementAt(1).Item2; 449 | } 450 | 451 | // Converts a portion of the base image to an average RGB color 452 | private SKColor[,] GetAverageColorGrid(SKBitmap bitmap, SKRectI bounds) 453 | { 454 | var rgbGrid = new SKColor[quadrantDivisionCount, quadrantDivisionCount]; 455 | int xDivisionSize = bounds.Width / quadrantDivisionCount; 456 | int yDivisionSize = bounds.Height / quadrantDivisionCount; 457 | 458 | for (int yDivisionIndex = 0; yDivisionIndex < quadrantDivisionCount; yDivisionIndex++) { 459 | for (int xDivisionIndex = 0; xDivisionIndex < quadrantDivisionCount; xDivisionIndex++) { 460 | 461 | int pixelCount = 0; 462 | int totalR = 0, totalG = 0, totalB = 0; 463 | 464 | for (int y = yDivisionIndex * yDivisionSize; y < (yDivisionIndex + 1) * yDivisionSize; y++) { 465 | for (int x = xDivisionIndex * xDivisionSize; x < (xDivisionIndex + 1) * xDivisionSize; x++) { 466 | 467 | var pixel = bitmap.GetPixel(x + bounds.Left, y + bounds.Top); 468 | 469 | totalR += pixel.Red; 470 | totalG += pixel.Green; 471 | totalB += pixel.Blue; 472 | pixelCount++; 473 | } 474 | } 475 | 476 | var finalR = (byte)(totalR / pixelCount); 477 | var finalG = (byte)(totalG / pixelCount); 478 | var finalB = (byte)(totalB / pixelCount); 479 | 480 | rgbGrid[xDivisionIndex, yDivisionIndex] = new SKColor(finalR, finalG, finalB); 481 | } 482 | } 483 | 484 | return rgbGrid; 485 | } 486 | 487 | public void Dispose() 488 | { 489 | foreach (var tileImage in tileImageRGBGridList) { 490 | tileImage.Item1.Dispose(); 491 | } 492 | } 493 | } 494 | } 495 | 496 | 497 | -------------------------------------------------------------------------------- /Client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Azure Functions Photo Mosaic Generator 7 | 8 | 9 | 10 | 12 | 13 | 14 | 179 | 180 | 181 | 185 | 186 | 187 | 188 | 189 |
190 |
191 |

OFFLINE - Mosaic Generator

192 |
193 |
194 |
195 |

1. Upload a photo

196 |
197 |
198 |
199 |
200 | Drop files here or click to upload.
201 |
202 |
203 |
204 |

2. The photo mosaic will appear below

205 |
206 |
207 |
208 | 215 |
216 | 217 | 437 | 438 | 439 | --------------------------------------------------------------------------------