diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1ff0c42 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,63 @@ +############################################################################### +# Set default behavior to automatically normalize line endings. +############################################################################### +* text=auto + +############################################################################### +# Set default behavior for command prompt diff. +# +# This is need for earlier builds of msysgit that does not have it on by +# default for csharp files. +# Note: This is only used by command line +############################################################################### +#*.cs diff=csharp + +############################################################################### +# Set the merge driver for project and solution files +# +# Merging from the command prompt will add diff markers to the files if there +# are conflicts (Merging from VS is not affected by the settings below, in VS +# the diff markers are never inserted). Diff markers may cause the following +# file extensions to fail to load in VS. An alternative would be to treat +# these files as binary and thus will always conflict and require user +# intervention with every merge. To do so, just uncomment the entries below +############################################################################### +#*.sln merge=binary +#*.csproj merge=binary +#*.vbproj merge=binary +#*.vcxproj merge=binary +#*.vcproj merge=binary +#*.dbproj merge=binary +#*.fsproj merge=binary +#*.lsproj merge=binary +#*.wixproj merge=binary +#*.modelproj merge=binary +#*.sqlproj merge=binary +#*.wwaproj merge=binary + +############################################################################### +# behavior for image files +# +# image files are treated as binary by default. +############################################################################### +#*.jpg binary +#*.png binary +#*.gif binary + +############################################################################### +# diff behavior for common document formats +# +# Convert binary document formats to text before diffing them. This feature +# is only available from the command line. Turn it on by uncommenting the +# entries below. +############################################################################### +#*.doc diff=astextplain +#*.DOC diff=astextplain +#*.docx diff=astextplain +#*.DOCX diff=astextplain +#*.dot diff=astextplain +#*.DOT diff=astextplain +#*.pdf diff=astextplain +#*.PDF diff=astextplain +#*.rtf diff=astextplain +#*.RTF diff=astextplain diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9491a2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,363 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd \ No newline at end of file diff --git a/ImageComparison.sln b/ImageComparison.sln new file mode 100644 index 0000000..33b6d5b --- /dev/null +++ b/ImageComparison.sln @@ -0,0 +1,41 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.33424.131 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ImageComparisonGUI", "ImageComparisonGUI\ImageComparisonGUI.csproj", "{2EB9DEEC-D4A0-47B0-B491-E2AEE3C20303}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ImageComparison", "ImageComparison\ImageComparison.csproj", "{A79455D8-BD6D-419A-AB94-73C4BD060B93}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2EB9DEEC-D4A0-47B0-B491-E2AEE3C20303}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2EB9DEEC-D4A0-47B0-B491-E2AEE3C20303}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2EB9DEEC-D4A0-47B0-B491-E2AEE3C20303}.Debug|x64.ActiveCfg = Debug|Any CPU + {2EB9DEEC-D4A0-47B0-B491-E2AEE3C20303}.Debug|x64.Build.0 = Debug|Any CPU + {2EB9DEEC-D4A0-47B0-B491-E2AEE3C20303}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2EB9DEEC-D4A0-47B0-B491-E2AEE3C20303}.Release|Any CPU.Build.0 = Release|Any CPU + {2EB9DEEC-D4A0-47B0-B491-E2AEE3C20303}.Release|x64.ActiveCfg = Release|Any CPU + {2EB9DEEC-D4A0-47B0-B491-E2AEE3C20303}.Release|x64.Build.0 = Release|Any CPU + {A79455D8-BD6D-419A-AB94-73C4BD060B93}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A79455D8-BD6D-419A-AB94-73C4BD060B93}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A79455D8-BD6D-419A-AB94-73C4BD060B93}.Debug|x64.ActiveCfg = Debug|x64 + {A79455D8-BD6D-419A-AB94-73C4BD060B93}.Debug|x64.Build.0 = Debug|x64 + {A79455D8-BD6D-419A-AB94-73C4BD060B93}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A79455D8-BD6D-419A-AB94-73C4BD060B93}.Release|Any CPU.Build.0 = Release|Any CPU + {A79455D8-BD6D-419A-AB94-73C4BD060B93}.Release|x64.ActiveCfg = Release|x64 + {A79455D8-BD6D-419A-AB94-73C4BD060B93}.Release|x64.Build.0 = Release|x64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {D5D12733-192D-4503-B06C-9DC05333AE02} + EndGlobalSection +EndGlobal diff --git a/ImageComparison/ImageComparison.csproj b/ImageComparison/ImageComparison.csproj new file mode 100644 index 0000000..78e2896 --- /dev/null +++ b/ImageComparison/ImageComparison.csproj @@ -0,0 +1,17 @@ + + + + Exe + net7.0 + enable + enable + AnyCPU;x64 + + + + + + + + + diff --git a/ImageComparison/Models/CacheItem.cs b/ImageComparison/Models/CacheItem.cs new file mode 100644 index 0000000..055e5e6 --- /dev/null +++ b/ImageComparison/Models/CacheItem.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace ImageComparison.Models +{ + public class CacheItem + { + public string path; + public ulong scantime; + public ulong size; + public byte[]? hash; + + public ulong[] hashArray { + get { + if (hash == null) + return Array.Empty(); + + ulong[] result = new ulong[(int)Math.Ceiling((decimal)hash.Length / 8)]; + + for (int i = 0; i * 8 < hash.Length; i++) + result[i] = BitConverter.ToUInt64(hash, i * 8); + + return result; + } + } + } +} diff --git a/ImageComparison/Models/DeleteAction.cs b/ImageComparison/Models/DeleteAction.cs new file mode 100644 index 0000000..099af78 --- /dev/null +++ b/ImageComparison/Models/DeleteAction.cs @@ -0,0 +1,9 @@ +namespace ImageComparison.Models +{ + public enum DeleteAction + { + Delete, + RecycleBin, + Move + } +} diff --git a/ImageComparison/Models/ImageAnalysis.cs b/ImageComparison/Models/ImageAnalysis.cs new file mode 100644 index 0000000..3750253 --- /dev/null +++ b/ImageComparison/Models/ImageAnalysis.cs @@ -0,0 +1,22 @@ +namespace ImageComparison.Models +{ + public class ImageAnalysis + { + public FileInfo Image { get; set; } + public ulong[] Hash { get; set; } + + public byte[] HashBlob { + get { + byte[] blob = new byte[Hash.Length * 8]; + + for (int i = 0; i < Hash.Length; i++) { + byte[] bytes = BitConverter.GetBytes(Hash[i]); + for(int j = 0; j < bytes.Length; j++) + blob[i * 8 + j] = bytes[j]; + } + + return blob; + } + } + } +} diff --git a/ImageComparison/Models/ImageMatch.cs b/ImageComparison/Models/ImageMatch.cs new file mode 100644 index 0000000..4c0eba3 --- /dev/null +++ b/ImageComparison/Models/ImageMatch.cs @@ -0,0 +1,9 @@ +namespace ImageComparison.Models +{ + public class ImageMatch + { + public ImageAnalysis Image1 { get; set; } + public ImageAnalysis Image2 { get; set; } + public short Similarity { get; set; } + } +} diff --git a/ImageComparison/Models/NoMatch.cs b/ImageComparison/Models/NoMatch.cs new file mode 100644 index 0000000..643835d --- /dev/null +++ b/ImageComparison/Models/NoMatch.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace ImageComparison.Models +{ + public class NoMatch + { + public string a; + public string b; + } +} diff --git a/ImageComparison/Models/SearchMode.cs b/ImageComparison/Models/SearchMode.cs new file mode 100644 index 0000000..6be42fa --- /dev/null +++ b/ImageComparison/Models/SearchMode.cs @@ -0,0 +1,11 @@ +namespace ImageComparison.Models +{ + public enum SearchMode + { + All, + Inclusive, + Exclusive, + ListInclusive, + ListExclusive + } +} diff --git a/ImageComparison/Program.cs b/ImageComparison/Program.cs new file mode 100644 index 0000000..3751555 --- /dev/null +++ b/ImageComparison/Program.cs @@ -0,0 +1,2 @@ +// See https://aka.ms/new-console-template for more information +Console.WriteLine("Hello, World!"); diff --git a/ImageComparison/Services/CacheService.cs b/ImageComparison/Services/CacheService.cs new file mode 100644 index 0000000..353fbcf --- /dev/null +++ b/ImageComparison/Services/CacheService.cs @@ -0,0 +1,167 @@ +using Dapper; +using ImageComparison.Models; +using Microsoft.Data.Sqlite; + +namespace ImageComparison.Services +{ + public static class CacheService + { + private static SqliteConnection connection; + + public static void Init() + { + try + { + Directory.CreateDirectory(FileService.DataDirectory); + connection = new SqliteConnection($"Data Source={Path.Combine(FileService.DataDirectory, "Cache.db")}"); + connection.Open(); + connection.Execute("CREATE TABLE IF NOT EXISTS file (path TEXT NOT NULL COLLATE NOCASE, scantime INTEGER NOT NULL, size INTEGER, hashtype TEXT, hash BLOB, UNIQUE(path, hashtype))"); + connection.Execute("CREATE TABLE IF NOT EXISTS nomatch (a TEXT NOT NULL COLLATE NOCASE, b TEXT NOT NULL COLLATE NOCASE, UNIQUE(a, b))"); + connection.Execute("CREATE INDEX IF NOT EXISTS idxf_ht ON file(hashtype)"); + connection.Close(); + } catch { } + } + + public static List GetImages(string hashtype) + { + List images = new(); + + try + { + connection.Open(); + + images = connection.Query("SELECT path, scantime, size, hash FROM file WHERE hashtype = @Hashtype", new { Hashtype = hashtype }).ToList(); + } catch { } + + connection.Close(); + + return images; + } + + public static void UpdateImages(List images, string hashtype, ulong scantime) + { + try + { + connection.Open(); + using (SqliteTransaction transaction = connection.BeginTransaction()) + using (SqliteCommand command = connection.CreateCommand()) + { + command.CommandText = "INSERT INTO file (path, scantime, size, hashtype, hash) VALUES (@Path, @Scantime, @Size, @Hashtype, @Hash) ON CONFLICT(path, hashtype) DO UPDATE SET scantime=@Scantime, size=@Size, hash=@Hash"; + command.Parameters.AddWithValue("@Path", "C:\\"); + command.Parameters.AddWithValue("@Scantime", 0); + command.Parameters.AddWithValue("@Size", 0); + command.Parameters.AddWithValue("@Hashtype", ""); + command.Parameters.AddWithValue("@Hash", Array.Empty()); + + images.ForEach(image => + { + command.Parameters["@Path"].Value = image.Image.FullName; + command.Parameters["@Scantime"].Value = scantime; + command.Parameters["@Size"].Value = image.Image.Length; + command.Parameters["@Hashtype"].Value = hashtype; + command.Parameters["@Hash"].Value = image.HashBlob; + command.ExecuteNonQuery(); + }); + + transaction.Commit(); + } + } + catch { } + + connection.Close(); + } + + public static void AddNoMatch(string a, string b) + { + try + { + int order = string.Compare(a, b); + if (order == 0) + return; + else if (order < 0) + (b, a) = (a, b); + + connection.Open(); + connection.Execute("INSERT INTO nomatch (a, b) VALUES (@a, @b) ON CONFLICT(a, b) DO NOTHING", new { a, b }); + } + catch { } + + connection.Close(); + } + + public static void AddNoMatches(List nomatches) + { + try + { + connection.Open(); + using (SqliteTransaction transaction = connection.BeginTransaction()) + using (SqliteCommand command = connection.CreateCommand()) + { + command.CommandText = "INSERT INTO nomatch (a, b) VALUES (@a, @b) ON CONFLICT(a, b) DO NOTHING"; + command.Parameters.AddWithValue("@a", ""); + command.Parameters.AddWithValue("@b", ""); + + nomatches.ForEach(nomatch => + { + string a, b; + int order = string.Compare(nomatch.Image1.Image.FullName, nomatch.Image2.Image.FullName); + if (order == 0) + return; + else if (order < 0) + (b, a) = (nomatch.Image1.Image.FullName, nomatch.Image2.Image.FullName); + else + (a, b) = (nomatch.Image1.Image.FullName, nomatch.Image2.Image.FullName); + + command.Parameters["@a"].Value = a; + command.Parameters["@b"].Value = b; + command.ExecuteNonQuery(); + }); + + transaction.Commit(); + } + } + catch { } + + connection.Close(); + } + + public static List GetNoMatches() + { + List nomatches = new(); + + try + { + connection.Open(); + + nomatches = connection.Query("SELECT * FROM nomatch").ToList(); + } + catch { } + + connection.Close(); + + return nomatches; + } + + public static void ClearImageCache() + { + try + { + connection.Open(); + connection.Execute("DELETE FROM file"); + } catch { } + + connection.Close(); + } + + public static void ClearNoMatchCache() + { + try + { + connection.Open(); + connection.Execute("DELETE FROM nomatch"); + } catch { } + + connection.Close(); + } + } +} diff --git a/ImageComparison/Services/CompareService.cs b/ImageComparison/Services/CompareService.cs new file mode 100644 index 0000000..9ccad41 --- /dev/null +++ b/ImageComparison/Services/CompareService.cs @@ -0,0 +1,235 @@ +using ImageComparison.Models; +using System.Timers; +using System.Collections.Concurrent; + +namespace ImageComparison.Services +{ + public class ImageComparerEventArgs + { + public int Current; + public int Target; + } + + public static class CompareService + { + public readonly static string[] SupportedFileTypes = { ".bmp", ".dib", ".jpg", ".jpeg", ".jpe", ".png", ".pbm", ".pgm", ".ppm", ".sr", ".ras", ".tiff", ".tif", ".exr", ".jp2" }; + + public static event EventHandler OnProgress = delegate {}; + + public static List> AnalyseImages(List> searchLocations, int hashDetail, bool hashBothDirections, List? cachedAnalysis, CancellationToken token = new()) + { + cachedAnalysis ??= new List(); + + List> analysed = new(); + + using (System.Timers.Timer ProgressTimer = new()) + { + int target = searchLocations.SelectMany(i => i).Count(); + + //dont overload cpu with too many threads, leave one core free + int threadCount = Environment.ProcessorCount > 1 ? Environment.ProcessorCount - 1 : 1; + + //update caller with current progress with events + ProgressTimer.Interval = 500; + ProgressTimer.AutoReset = true; + ProgressTimer.Elapsed += (object? source, ElapsedEventArgs e) => + { + OnProgress.Invoke(null, new ImageComparerEventArgs() + { + Current = analysed.SelectMany(a => a).Count(), + Target = target + }); + }; + ProgressTimer.Start(); + + //keep searchLocations separate for later comparisons depending on search mode + searchLocations.ForEach(location => + { + ConcurrentBag locationAnalysis = new(); + analysed.Add(locationAnalysis); + + Parallel.ForEach(location, new(){ MaxDegreeOfParallelism = threadCount }, file => + { + if (token.IsCancellationRequested) + return; + + try + { + CacheItem? cachedImage = cachedAnalysis.FirstOrDefault(c => c.path == file.FullName); + locationAnalysis.Add(new() + { + Image = file, + Hash = cachedImage != null && cachedImage.hash != null && cachedImage.scantime > (ulong)(file.LastWriteTime - new DateTime(1970, 1, 1, 0, 0, 0, 0)).TotalSeconds ? cachedImage.hashArray : CalculateHash(file.FullName, hashDetail, hashBothDirections) + }); + } + catch (Exception e) { } + }); + }); + + ProgressTimer.Stop(); + + OnProgress.Invoke(null, new ImageComparerEventArgs() + { + Current = target, + Target = target + }); + } + + return analysed.Select(a => a.ToList()).ToList(); + } + + public static List SearchForDuplicates(List> analysedLocations, int matchThreashold, SearchMode mode, List? nomatches, CancellationToken token = new()) + { + nomatches ??= new(); + + switch(mode) + { + case SearchMode.ListExclusive: + //calculate if many locations or many files within each location given + double filesPerLocation = ((double)analysedLocations.Count * analysedLocations.SelectMany(i => i).Count()) / analysedLocations.Count; + + //dont overload cpu with too many threads, leave one core free + int threadCount = Environment.ProcessorCount > 1 ? Environment.ProcessorCount - 1 : 1; + + ConcurrentBag comparisons = new(); + + //Run in parallel if more locations than file per location, else run files within locations in parallel + Parallel.ForEach(analysedLocations, new() { MaxDegreeOfParallelism = filesPerLocation <= analysedLocations.Count ? threadCount : 1 }, (images, state, currentLocation) => + { + if (token.IsCancellationRequested) + return; + + Parallel.ForEach(images, new() { MaxDegreeOfParallelism = filesPerLocation > analysedLocations.Count ? threadCount : 1 }, (image) => + { + for(int location = (int)currentLocation + 1; location < analysedLocations.Count; location++) + { + + analysedLocations[location].ForEach(comparer => + { + if (token.IsCancellationRequested) + return; + + short similarity = CalculateSimilarity(image.Hash, comparer.Hash); + if (similarity >= matchThreashold && !IsNoMatch(nomatches, image.Image.FullName, comparer.Image.FullName)) + { + comparisons.Add(new() + { + Image1 = image, + Image2 = comparer, + Similarity = similarity + }); + } + }); + } + }); + }); + + return SortMatches(comparisons); + case SearchMode.ListInclusive: + return analysedLocations + .SelectMany(location => SearchForDuplicates(location, matchThreashold, nomatches, token)) + .ToList(); + case SearchMode.Exclusive: + return SearchForDuplicates( + analysedLocations + .SelectMany(location => + location + .GroupBy(directory => directory.Image.DirectoryName) + .Select(image => image.ToList()) + .ToList() + ) + .ToList(), + matchThreashold, + SearchMode.ListExclusive, + nomatches, + token); + case SearchMode.Inclusive: + return analysedLocations + .SelectMany(images => { + return images + .GroupBy(image => image.Image.DirectoryName) + .SelectMany(directory => SearchForDuplicates(directory.ToList(), matchThreashold, nomatches, token)); + }) + .ToList(); + default: + return SearchForDuplicates(analysedLocations.SelectMany(images => images).ToList(), matchThreashold, nomatches, token); + } + } + + public static List SearchForDuplicates(List images, int matchThreashold, List nomatches, CancellationToken token = new()) + { + nomatches ??= new(); + + ConcurrentBag comparisons = new(); + + //dont overload cpu with too many threads, leave one core free + int threadCount = Environment.ProcessorCount > 1 ? Environment.ProcessorCount - 1 : 1; + + Parallel.ForEach(images, new() { MaxDegreeOfParallelism = threadCount }, (image, state, index) => + { + for (int i = Convert.ToInt32(index) + 1; i < images.Count; i++) + { + + if (token.IsCancellationRequested) + return; + + short similarity = CalculateSimilarity(image.Hash, images[i].Hash); + if (similarity >= matchThreashold && !IsNoMatch(nomatches, image.Image.FullName, images[i].Image.FullName)) + { + comparisons.Add(new() + { + Image1 = image, + Image2 = images[i], + Similarity = similarity + }); + } + } + }); + + return SortMatches(comparisons); + } + + //Calculate Hash Values by ImageHash (Dr. Neal Krawetz algorithms) + private static ulong[] CalculateHash(string file, int detail, bool bothDirections) + { + using (Stream stream = File.OpenRead(file)) + { + return HashService.DHash(Image.Load(stream), detail, bothDirections); + } + } + + private static short CalculateSimilarity(ulong[] hash1, ulong[] hash2) + { + return HashService.Similarity(hash1, hash2); + } + + private static List SortMatches(ConcurrentBag comparisons) + { + List matches = comparisons.ToList(); + matches.Sort((a,b) => + { + int result = b.Similarity - a.Similarity; + if (result == 0) + return string.Compare(a.Image1.Image.FullName, b.Image1.Image.FullName); + + return result; + }); + + return matches; + } + + private static bool IsNoMatch(List nomatches, string a, string b) + { + if(nomatches.Count == 0) + return false; + + int order = string.Compare(a, b); + if (order == 0) + return true; + else if (order < 0) + (b, a) = (a, b); + + return nomatches.Any(n => n.a == a && n.b == b); + } + } +} diff --git a/ImageComparison/Services/FileService.cs b/ImageComparison/Services/FileService.cs new file mode 100644 index 0000000..4f52cec --- /dev/null +++ b/ImageComparison/Services/FileService.cs @@ -0,0 +1,83 @@ +using ImageComparison.Models; +using Microsoft.VisualBasic.FileIO; +using System.Collections.Immutable; + +namespace ImageComparison.Services +{ + public static class FileService + { + public static readonly string DataDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "DerEffi", "ImageComparison"); + + public static void DeleteFile(string path, DeleteAction deleteAction = DeleteAction.Delete, string target = "Duplicates\\", bool relativeTarget = true) + { + if (path == null || !File.Exists(path)) + throw new FileNotFoundException(); + + switch(deleteAction) + { + case DeleteAction.Delete: + File.Delete(path); + break; + case DeleteAction.RecycleBin: + FileSystem.DeleteFile(path, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin); + break; + case DeleteAction.Move: + + if (string.IsNullOrEmpty(Path.GetDirectoryName(path))) + throw new DirectoryNotFoundException(); + + string targetPath = relativeTarget ? Path.Combine(Path.GetDirectoryName(path), target) : target; + + if (File.Exists(targetPath)) + throw new IOException(); + + if(!Directory.Exists(targetPath)) + Directory.CreateDirectory(targetPath); + + string targetFile = Path.Combine(targetPath + Path.GetFileName(path)); + int counter = 0; + while (File.Exists(targetFile)) + { + targetFile = Path.Combine(targetPath, Path.GetFileNameWithoutExtension(path) + "-" + ++counter + Path.GetExtension(path)); + } + + File.Move(path, targetFile); + + break; + } + } + + public static List> GetProcessableFiles(string[] searchLocations, bool searchSubdirectories) + { + List> directories = new(); + + foreach(string location in searchLocations) + { + List directory = new(); + + if (string.IsNullOrEmpty(location) || !Directory.Exists(location)) + continue; + + try + { + List current = Directory + .GetFiles(location, $"*.*", System.IO.SearchOption.TopDirectoryOnly) + .Where(path => CompareService.SupportedFileTypes.Any(ext => path.ToLower().EndsWith(ext))) + .Select(path => new FileInfo(path)) + .ToList(); + + if(current.Count != 0) + directory.AddRange(current); + + if(searchSubdirectories) + directory.AddRange(GetProcessableFiles(Directory.GetDirectories(location), true).SelectMany(i => i)); + } catch { } + + if (directory.Count != 0) + directories.Add(directory); + } + + return directories; + } + } +} diff --git a/ImageComparison/Services/HashService.cs b/ImageComparison/Services/HashService.cs new file mode 100644 index 0000000..9f561f3 --- /dev/null +++ b/ImageComparison/Services/HashService.cs @@ -0,0 +1,105 @@ +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Processing; +using System.Diagnostics.CodeAnalysis; +using System.Collections; +using System.Numerics; +using ImageComparison.Models; + +namespace ImageComparison.Services +{ + public static class HashService + { + public static int Version = 1; + + public static ulong[] DHash(Image image, int detail = 8, bool bothDirections = false) + { + if (image == null) + { + throw new ArgumentNullException(nameof(image)); + } + + int bothDirectionsNumber = Convert.ToInt32(bothDirections); + + image.Mutate(ctx => ctx + .AutoOrient() + .Resize(detail + 1, detail + bothDirectionsNumber) + .Grayscale(GrayscaleMode.Bt601)); + + int pixelCount = detail * detail * (bothDirectionsNumber + 1); + int currentHashIndex = 0; + ulong[] hash = new ulong[(int)Math.Ceiling((double)pixelCount / 64)]; //reserve number of ulongs to hold bits of pixel comparisons + + image.ProcessPixelRows((imageAccessor) => + { + ulong mask = 1UL << 63; + Span lastRow = bothDirections ? imageAccessor.GetRowSpan(0) : null; + + for (var y = bothDirectionsNumber; y < detail + bothDirectionsNumber; y++) + { + Span row = imageAccessor.GetRowSpan(y); + Rgba32 leftPixel = row[0]; + + for (var index = 1; index < detail + 1; index++) + { + //if current ulong is full, switch to next and reset mask + if (mask == 0) + { + currentHashIndex++; + mask = 1UL << 63; + } + + Rgba32 rightPixel = row[index]; + if (leftPixel.R < rightPixel.R) + { + hash[currentHashIndex] |= mask; + } + + leftPixel = rightPixel; + mask >>= 1; + + if(bothDirections) + { + if(rightPixel.R < lastRow[index].R) + hash[currentHashIndex] |= mask; + mask >>= 1; + } + } + + if (bothDirections) + lastRow = row; + } + + }); + + return hash; + } + + public static short Similarity(ulong[] hash1, ulong[] hash2) + { + if((hash2 == null) || hash1.Length != hash2.Length) + throw new ArgumentOutOfRangeException(nameof(hash2)); + + int hashLength = hash2.Length * 64; + + return Convert.ToInt16(Math.Floor((double)(hashLength - HammingDistance(hash1, hash2)) * 10000 / hashLength)); + } + + private static int HammingDistance(ulong[] hash1, ulong[] hash2) + { + int bitcount = 0; + for(int i = 0; i < hash1.Length; i++) + { + bitcount += HammingWeight(hash1[i] ^ hash2[i]); + } + return bitcount; + } + + private static int HammingWeight(ulong i) + { + i -= ((i >> 1) & 0x5555555555555555UL); + i = (i & 0x3333333333333333UL) + ((i >> 2) & 0x3333333333333333UL); + return (int)(unchecked(((i + (i >> 4)) & 0xF0F0F0F0F0F0F0FUL) * 0x101010101010101UL) >> 56); + } + } +} diff --git a/ImageComparisonGUI/.gitignore b/ImageComparisonGUI/.gitignore new file mode 100644 index 0000000..8afdcb6 --- /dev/null +++ b/ImageComparisonGUI/.gitignore @@ -0,0 +1,454 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# JetBrains Rider +.idea/ +*.sln.iml + +## +## Visual Studio Code +## +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json diff --git a/ImageComparisonGUI/App.axaml b/ImageComparisonGUI/App.axaml new file mode 100644 index 0000000..08908e8 --- /dev/null +++ b/ImageComparisonGUI/App.axaml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/ImageComparisonGUI/App.axaml.cs b/ImageComparisonGUI/App.axaml.cs new file mode 100644 index 0000000..8b9a2d6 --- /dev/null +++ b/ImageComparisonGUI/App.axaml.cs @@ -0,0 +1,31 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Data.Core; +using Avalonia.Data.Core.Plugins; +using Avalonia.Markup.Xaml; +using ImageComparisonGUI.Services; +using ImageComparisonGUI.Views; + +namespace ImageComparisonGUI +{ + public partial class App : Application + { + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + // Line below is needed to remove Avalonia data validation. + // Without this line you will get duplicate validations from both Avalonia and CT + ExpressionObserver.DataValidators.RemoveAll(x => x is DataAnnotationsValidationPlugin); + desktop.MainWindow = new MainWindow(); + } + + base.OnFrameworkInitializationCompleted(); + } + } +} \ No newline at end of file diff --git a/ImageComparisonGUI/Assets/gallery.ico b/ImageComparisonGUI/Assets/gallery.ico new file mode 100644 index 0000000..4906298 Binary files /dev/null and b/ImageComparisonGUI/Assets/gallery.ico differ diff --git a/ImageComparisonGUI/Assets/gallery.svg b/ImageComparisonGUI/Assets/gallery.svg new file mode 100644 index 0000000..5a41536 --- /dev/null +++ b/ImageComparisonGUI/Assets/gallery.svg @@ -0,0 +1,82 @@ + + + + diff --git a/ImageComparisonGUI/ImageComparisonGUI.csproj b/ImageComparisonGUI/ImageComparisonGUI.csproj new file mode 100644 index 0000000..e8bf4c6 --- /dev/null +++ b/ImageComparisonGUI/ImageComparisonGUI.csproj @@ -0,0 +1,55 @@ + + + WinExe + net7.0 + enable + true + app.manifest + ImageComparisonGUI.Program + DerEffi.$(AssemblyName) + Image Comparison + DerEffi + https://github.com/DerEffi/image-comparison + https://github.com/DerEffi/image-comparison + git + 2023.6.0.0 + Assets\gallery.ico + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AdjustablesPage.axaml + + + HotkeysPage.axaml + + + diff --git a/ImageComparisonGUI/Models/Hotkey.cs b/ImageComparisonGUI/Models/Hotkey.cs new file mode 100644 index 0000000..ea63996 --- /dev/null +++ b/ImageComparisonGUI/Models/Hotkey.cs @@ -0,0 +1,28 @@ +using Avalonia.Input; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace ImageComparisonGUI.Models +{ + public class Hotkey + { + [JsonConverter(typeof(StringEnumConverter))] + public Key Key = Key.None; + + [JsonConverter(typeof(StringEnumConverter))] + public KeyModifiers Modifiers = KeyModifiers.None; + + [JsonConverter(typeof(StringEnumConverter))] + public HotkeyTarget Target = HotkeyTarget.None; + + public Hotkey Clone() + { + return (Hotkey)MemberwiseClone(); + } + } +} diff --git a/ImageComparisonGUI/Models/HotkeyEventArgs.cs b/ImageComparisonGUI/Models/HotkeyEventArgs.cs new file mode 100644 index 0000000..527ca86 --- /dev/null +++ b/ImageComparisonGUI/Models/HotkeyEventArgs.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace ImageComparisonGUI.Models +{ + public class HotkeyEventArgs + { + public Hotkey PressedHotkey = new(); + public string SelectedPage = ""; + } +} diff --git a/ImageComparisonGUI/Models/HotkeyTarget.cs b/ImageComparisonGUI/Models/HotkeyTarget.cs new file mode 100644 index 0000000..55ecfc7 --- /dev/null +++ b/ImageComparisonGUI/Models/HotkeyTarget.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace ImageComparisonGUI.Models +{ + public enum HotkeyTarget + { + None, + SearchNoMatch, + SearchPrevious, + SearchDeleteLeft, + SearchDeleteRight, + SearchDeleteBoth, + SearchAuto, + SearchAbort, + SearchStart + } +} diff --git a/ImageComparisonGUI/Models/Profile.cs b/ImageComparisonGUI/Models/Profile.cs new file mode 100644 index 0000000..ae22b9b --- /dev/null +++ b/ImageComparisonGUI/Models/Profile.cs @@ -0,0 +1,8 @@ +namespace ImageComparisonGUI.Models +{ + public class Profile + { + public string Name; + public Settings Settings = new(); + } +} diff --git a/ImageComparisonGUI/Models/Settings.cs b/ImageComparisonGUI/Models/Settings.cs new file mode 100644 index 0000000..dfda35b --- /dev/null +++ b/ImageComparisonGUI/Models/Settings.cs @@ -0,0 +1,113 @@ +using Avalonia.Input; +using ImageComparison.Models; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using static Dapper.SqlMapper; + +namespace ImageComparisonGUI.Models +{ + public class Settings + { + //Cache settings + public bool CacheNoMatch = true; + public bool CacheImages = true; + + //Processing settings + public int MatchThreashold = 7500; + public int HashDetail = 20; + public bool HashBothDirections = true; + + //Location settings + public string[] SearchLocations = Array.Empty(); + [JsonConverter(typeof(StringEnumConverter))] + public SearchMode SearchMode = SearchMode.All; + public bool SearchSubdirectories = true; + + //Deletion settings + [JsonConverter(typeof(StringEnumConverter))] + public DeleteAction DeleteAction = DeleteAction.RecycleBin; + public string DeleteTarget = "Duplicates\\"; + public bool RelativeDeleteTarget = true; + + public List Hotkeys = new() { + new() { + Key = Key.S, + Modifiers = KeyModifiers.None, + Target = HotkeyTarget.SearchAuto + }, + new() { + Key = Key.N, + Modifiers = KeyModifiers.None, + Target = HotkeyTarget.SearchNoMatch + }, + new() { + Key = Key.A, + Modifiers = KeyModifiers.Control, + Target = HotkeyTarget.SearchAbort + }, + new() { + Key = Key.Z, + Modifiers = KeyModifiers.Control, + Target = HotkeyTarget.SearchPrevious + }, + new() { + Key = Key.Enter, + Modifiers = KeyModifiers.None, + Target = HotkeyTarget.SearchStart + }, + }; + + /// + /// Deepcopy the Settings object + /// + /// The new unrelated Settings object + public Settings Clone() + { + this.DistinguishHotkeys(); + return JsonConvert.DeserializeObject(JsonConvert.SerializeObject(this)) ?? new(); + } + + /// + /// Parse string json representation of settings to Settings object + /// + /// Read json string representation of settings from i.e. a file + /// Settings object parsed from given content or null + public static Settings? Parse(string content) + { + try + { + Settings? settings = JsonConvert.DeserializeObject(content, new JsonSerializerSettings() { ObjectCreationHandling = ObjectCreationHandling.Replace }); + if (settings != null) + { + settings.DistinguishHotkeys(); + } + return settings; + } catch { } + + return null; + } + + /// + /// Get settings as text (byte content) for saving + /// + /// UTF8 encoded text as byte array + public byte[] GetContent() + { + this.DistinguishHotkeys(); + return new UTF8Encoding(true).GetBytes(JsonConvert.SerializeObject(this, Formatting.Indented)); + } + + /// + /// Removes double Hotkey targets and double key combinations in place + /// + public void DistinguishHotkeys() + { + this.Hotkeys.RemoveAll(h => h.Key == Key.None); + this.Hotkeys = this.Hotkeys.DistinctBy(h => h.Target).DistinctBy(h => $"{h.Modifiers}-{h.Key}").ToList(); + } + } +} diff --git a/ImageComparisonGUI/Pages/AboutPage.axaml b/ImageComparisonGUI/Pages/AboutPage.axaml new file mode 100644 index 0000000..9a952a9 --- /dev/null +++ b/ImageComparisonGUI/Pages/AboutPage.axaml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ImageComparisonGUI/Pages/AboutPage.axaml.cs b/ImageComparisonGUI/Pages/AboutPage.axaml.cs new file mode 100644 index 0000000..1804207 --- /dev/null +++ b/ImageComparisonGUI/Pages/AboutPage.axaml.cs @@ -0,0 +1,18 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using CommunityToolkit.Mvvm.Input; +using ImageComparisonGUI.Services; +using ImageComparisonGUI.ViewModels; + +namespace ImageComparisonGUI.Pages +{ + public partial class AboutPage : UserControl + { + public AboutPage() + { + InitializeComponent(); + DataContext = new AboutPageViewModel(); + } + } +} diff --git a/ImageComparisonGUI/Pages/AdjustablesPage.axaml b/ImageComparisonGUI/Pages/AdjustablesPage.axaml new file mode 100644 index 0000000..c75428c --- /dev/null +++ b/ImageComparisonGUI/Pages/AdjustablesPage.axaml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ImageComparisonGUI/Pages/AdjustablesPage.axaml.cs b/ImageComparisonGUI/Pages/AdjustablesPage.axaml.cs new file mode 100644 index 0000000..f8442a5 --- /dev/null +++ b/ImageComparisonGUI/Pages/AdjustablesPage.axaml.cs @@ -0,0 +1,17 @@ +using Avalonia.Controls; +using ImageComparisonGUI.ViewModels; + +namespace ImageComparisonGUI.Pages +{ + public partial class AdjustablesPage : UserControl + { + public AdjustablesPage() + { + InitializeComponent(); + + Slider matchThreasholdSlider = this.Find("MatchThreasholdSlider"); + Slider hashDetailSlider = this.Find("HashDetailSlider"); + DataContext = new AdjustablesPageViewModel(matchThreasholdSlider, hashDetailSlider); + } + } +} diff --git a/ImageComparisonGUI/Pages/CachePage.axaml b/ImageComparisonGUI/Pages/CachePage.axaml new file mode 100644 index 0000000..70a0951 --- /dev/null +++ b/ImageComparisonGUI/Pages/CachePage.axaml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +