From 51f27bdd42719134623c68165371abf64851c823 Mon Sep 17 00:00:00 2001 From: Anthony Truskinger Date: Thu, 4 Apr 2019 17:45:02 +1000 Subject: [PATCH] [WIP] Initial work for drawing ribbon plots --- .editorconfig | 4 +- src/Acoustics.Shared/AppConfigHelper.cs | 1 + .../DateTimeAndTimeSpanExtensions.cs | 18 +- .../Extensions/DictionaryExtensions.cs | 11 +- .../Extensions/EnumerableExtensions.cs | 4 +- .../Extensions/FileInfoExtensions.cs | 11 + src/Acoustics.Shared/FileDateHelpers.cs | 48 ++++ .../AnalyseLongRecording.cs | 6 +- src/AnalysisPrograms/AnalysisPrograms.csproj | 29 +++ src/AnalysisPrograms/App.config | 4 + src/AnalysisPrograms/ConcatenateIndexFiles.cs | 10 +- src/AnalysisPrograms/Production/Exceptions.cs | 20 +- .../RibbonPlots/RibbonPlot.Arguments.cs | 59 +++++ .../RibbonPlots/RibbonPlot.Entry.cs | 228 ++++++++++++++++++ src/AnalysisPrograms/packages.config | 9 + .../Indices/IndexGenerationData.cs | 28 ++- 16 files changed, 466 insertions(+), 24 deletions(-) create mode 100644 src/AnalysisPrograms/RibbonPlots/RibbonPlot.Arguments.cs create mode 100644 src/AnalysisPrograms/RibbonPlots/RibbonPlot.Entry.cs diff --git a/.editorconfig b/.editorconfig index 75c4b201a..ea6251c69 100644 --- a/.editorconfig +++ b/.editorconfig @@ -96,8 +96,8 @@ csharp_style_var_when_type_is_apparent = true:none csharp_style_var_elsewhere = true:none # Expression-bodied members # https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-code-style-settings-reference#expression_bodied_members -csharp_style_expression_bodied_methods = when_on_single_line:warning -csharp_style_expression_bodied_constructors = when_on_single_line:warning +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_constructors = when_on_single_line:suggestion csharp_style_expression_bodied_operators = true:warning csharp_style_expression_bodied_properties = true:warning csharp_style_expression_bodied_indexers = true:warning diff --git a/src/Acoustics.Shared/AppConfigHelper.cs b/src/Acoustics.Shared/AppConfigHelper.cs index bc2e94621..2a5045b87 100644 --- a/src/Acoustics.Shared/AppConfigHelper.cs +++ b/src/Acoustics.Shared/AppConfigHelper.cs @@ -34,6 +34,7 @@ public static class AppConfigHelper public const string StandardDateFormatNoTimeZone = "yyyyMMdd-HHmmss"; public const string StandardDateFormatUnderscore = "yyyyMMdd_HHmmsszzz"; public const string StandardDateFormatSm2 = "yyyyMMdd_HHmmss"; + public const string RenderedDateFormatShort = "yyyy-MM-dd HH:mm"; private static readonly string ExecutingAssemblyPath = (Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly()).Location; diff --git a/src/Acoustics.Shared/Extensions/DateTimeAndTimeSpanExtensions.cs b/src/Acoustics.Shared/Extensions/DateTimeAndTimeSpanExtensions.cs index e2846ab67..7dd5aec80 100644 --- a/src/Acoustics.Shared/Extensions/DateTimeAndTimeSpanExtensions.cs +++ b/src/Acoustics.Shared/Extensions/DateTimeAndTimeSpanExtensions.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // All code in this file and all associated files are the copyright and property of the QUT Ecoacoustics Research Group (formerly MQUTeR, and formerly QUT Bioacoustics Research Group). // @@ -282,7 +282,7 @@ public static DateTime Round(this DateTime datetime, TimeSpan roundingInterval) } /// - /// Multiplies a timespan by a scalar value + /// Multiplies a timespan by a scalar value. /// public static TimeSpan Multiply(this TimeSpan multiplicand, int multiplier) { @@ -290,7 +290,7 @@ public static TimeSpan Multiply(this TimeSpan multiplicand, int multiplier) } /// - /// Divides a timespan by an scalar value + /// Divides a timespan by an scalar value. /// public static TimeSpan Divide(this TimeSpan dividend, int divisor) { @@ -298,7 +298,7 @@ public static TimeSpan Divide(this TimeSpan dividend, int divisor) } /// - /// Multiplies a timespan by a double value + /// Multiplies a timespan by a double value. /// public static TimeSpan Multiply(this TimeSpan multiplicand, double multiplier) { @@ -306,13 +306,21 @@ public static TimeSpan Multiply(this TimeSpan multiplicand, double multiplier) } /// - /// Divides a timespan by an scalar value + /// Divides a timespan by an scalar value. /// public static TimeSpan Divide(this TimeSpan dividend, double divisor) { return TimeSpan.FromTicks((long)(dividend.Ticks / divisor)); } + /// + /// Divides a timespan by an scalar value. + /// + public static double Divide(this TimeSpan dividend, TimeSpan divisor) + { + return dividend.Ticks / (double)divisor.Ticks; + } + // https://github.com/exceptionless/Exceptionless.DateTimeExtensions/blob/master/src/Exceptionless.DateTimeExtensions/DateTimeOffsetExtensions.cs#L222 public static DateTimeOffset Floor(this DateTimeOffset date, TimeSpan interval) { diff --git a/src/Acoustics.Shared/Extensions/DictionaryExtensions.cs b/src/Acoustics.Shared/Extensions/DictionaryExtensions.cs index 68a80b1ab..db0e9b69a 100644 --- a/src/Acoustics.Shared/Extensions/DictionaryExtensions.cs +++ b/src/Acoustics.Shared/Extensions/DictionaryExtensions.cs @@ -1,4 +1,4 @@ -// +// // All code in this file and all associated files are the copyright and property of the QUT Ecoacoustics Research Group (formerly MQUTeR, and formerly QUT Bioacoustics Research Group). // // ReSharper disable once CheckNamespace @@ -36,5 +36,14 @@ public static TValue FirstValue(this Dictionary dict return dictionary.Values.First(); } + + public static void Deconstruct( + this KeyValuePair kvp, + out TKey key, + out TValue value) + { + key = kvp.Key; + value = kvp.Value; + } } } diff --git a/src/Acoustics.Shared/Extensions/EnumerableExtensions.cs b/src/Acoustics.Shared/Extensions/EnumerableExtensions.cs index b4098c252..5744b79c0 100644 --- a/src/Acoustics.Shared/Extensions/EnumerableExtensions.cs +++ b/src/Acoustics.Shared/Extensions/EnumerableExtensions.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // All code in this file and all associated files are the copyright and property of the QUT Ecoacoustics Research Group (formerly MQUTeR, and formerly QUT Bioacoustics Research Group). // @@ -16,7 +16,7 @@ namespace System public static class EnumerableExtensions { - [ContractAnnotation("items:null => true; items:notnull => false")] + [ContractAnnotation("items:null => true")] public static bool IsNullOrEmpty(this IEnumerable items) { return items == null || !items.Any(); diff --git a/src/Acoustics.Shared/Extensions/FileInfoExtensions.cs b/src/Acoustics.Shared/Extensions/FileInfoExtensions.cs index b717597b1..9444a23db 100644 --- a/src/Acoustics.Shared/Extensions/FileInfoExtensions.cs +++ b/src/Acoustics.Shared/Extensions/FileInfoExtensions.cs @@ -172,6 +172,17 @@ public static string BaseName(this FileInfo file) { return Path.GetFileNameWithoutExtension(file.Name); } + + public static string FormatList(this IEnumerable infos) + { + var builder = new StringBuilder("\n", 1000); + foreach (var info in infos) + { + builder.AppendFormat("\t- {0}\n", info.FullName); + } + + return builder.ToString(); + } } public class FileInfoNameComparer : IComparer, IEqualityComparer diff --git a/src/Acoustics.Shared/FileDateHelpers.cs b/src/Acoustics.Shared/FileDateHelpers.cs index 9ea1c7e40..d5eb7170d 100644 --- a/src/Acoustics.Shared/FileDateHelpers.cs +++ b/src/Acoustics.Shared/FileDateHelpers.cs @@ -98,6 +98,54 @@ public static SortedDictionary FilterFilesForDates(IEn return datesAndFiles; } + /// + /// sorts a list of files by the date assumed to be encoded in their file names + /// and then returns the list as a sorted dictionary with file DateTime as the keys. + /// + /// The files to filter. + /// If you know what timezone you should have, specify a hint to enable parsing of ambiguous dates. + /// A sorted dictionary FileInfo objects mapped to parsed dates. + public static SortedDictionary FilterObjectsForDates(IEnumerable objects, Func pathSelector, Func overrideSelector, TimeSpan? offsetHint = null) + { + var datesAndFiles = new SortedDictionary(); + foreach (var @object in objects) + { + var date = overrideSelector?.Invoke(@object); + + FileSystemInfo file = null; + if (date == null) + { + file = pathSelector.Invoke(@object); + if (FileNameContainsDateTime(file.Name, out var parsedDate, offsetHint)) + { + date = parsedDate; + } + } + + if (date.NotNull()) + { + if (datesAndFiles.ContainsKey(date.Value)) + { + if (file == null) + { + file = pathSelector.Invoke(@object); + } + + string message = + $"There was a duplicate date. File {file} with date {date,'r'} conflicts with existing file {datesAndFiles[date.Value]}"; + throw new InvalidDataSetException(message); + } + + datesAndFiles.Add(date.Value, @object); + } + } + + // use following lines to get first and last date from returned dictionary + //DateTimeOffset firstdate = datesAndFiles[datesAndFiles.Keys.First()]; + //DateTimeOffset lastdate = datesAndFiles[datesAndFiles.Keys.Last()]; + return datesAndFiles; + } + /// /// sorts a list of files by the date assumed to be encoded in their file names /// and then returns the list as a sorted dictionary with file DateTime as the keys. diff --git a/src/AnalysisPrograms/AnalyseLongRecordings/AnalyseLongRecording.cs b/src/AnalysisPrograms/AnalyseLongRecordings/AnalyseLongRecording.cs index 126281b40..bce7749c6 100644 --- a/src/AnalysisPrograms/AnalyseLongRecordings/AnalyseLongRecording.cs +++ b/src/AnalysisPrograms/AnalyseLongRecordings/AnalyseLongRecording.cs @@ -31,7 +31,7 @@ namespace AnalysisPrograms.AnalyseLongRecordings public partial class AnalyseLongRecording { - private const string ImagefileExt = "png"; + private const string ImageFileExt = "png"; private static readonly ILog Log = LogManager.GetLogger(nameof(AnalyseLongRecording)); @@ -101,7 +101,7 @@ public static void Execute(Arguments arguments) } // 2. initialize the analyzer - // we're changing the way resolving config files works. Ideally, we'd like to use staticly typed config files + // we're changing the way resolving config files works. Ideally, we'd like to use statically typed config files // but we can't do that unless we know which type we have to load first! Currently analyzer to load is in // the config file so we can't know which analyzer we can use. Thus we will change to using the file name, // or an argument to resolve the analyzer to load. @@ -328,7 +328,7 @@ public static void Execute(Arguments arguments) imageTitle, timeScale, fileSegment.TargetFileStartDate); - var imagePath = FilenameHelpers.AnalysisResultPath(instanceOutputDirectory, basename, "SummaryIndices", ImagefileExt); + var imagePath = FilenameHelpers.AnalysisResultPath(instanceOutputDirectory, basename, "SummaryIndices", ImageFileExt); tracksImage.Save(imagePath); } } diff --git a/src/AnalysisPrograms/AnalysisPrograms.csproj b/src/AnalysisPrograms/AnalysisPrograms.csproj index 89ddbf9a4..ed4e994f7 100644 --- a/src/AnalysisPrograms/AnalysisPrograms.csproj +++ b/src/AnalysisPrograms/AnalysisPrograms.csproj @@ -119,6 +119,21 @@ ..\..\packages\Microsoft.Win32.Primitives.4.3.0\lib\net46\Microsoft.Win32.Primitives.dll + + ..\..\packages\SixLabors.Core.1.0.0-beta0007\lib\netstandard2.0\SixLabors.Core.dll + + + ..\..\packages\SixLabors.Fonts.1.0.0-beta0008\lib\netstandard2.0\SixLabors.Fonts.dll + + + ..\..\packages\SixLabors.ImageSharp.1.0.0-beta0006\lib\netstandard2.0\SixLabors.ImageSharp.dll + + + ..\..\packages\SixLabors.ImageSharp.Drawing.1.0.0-beta0006\lib\netstandard2.0\SixLabors.ImageSharp.Drawing.dll + + + ..\..\packages\SixLabors.Shapes.1.0.0-beta0008\lib\netstandard2.0\SixLabors.Shapes.dll + ..\..\packages\SQLitePCLRaw.bundle_green.1.1.9\lib\net45\SQLitePCLRaw.batteries_green.dll @@ -136,6 +151,9 @@ ..\..\packages\System.AppContext.4.3.0\lib\net46\System.AppContext.dll True + + ..\..\packages\System.Buffers.4.4.0\lib\netstandard2.0\System.Buffers.dll + ..\..\packages\System.ComponentModel.Annotations.4.5.0\lib\net461\System.ComponentModel.Annotations.dll @@ -184,6 +202,9 @@ ..\..\packages\System.IO.FileSystem.Primitives.4.3.0\lib\net46\System.IO.FileSystem.Primitives.dll True + + ..\..\packages\System.Memory.4.5.1\lib\netstandard2.0\System.Memory.dll + ..\..\packages\System.Net.Http.4.3.0\lib\net46\System.Net.Http.dll @@ -201,6 +222,9 @@ True + + ..\..\packages\System.Numerics.Vectors.4.5.0\lib\net46\System.Numerics.Vectors.dll + ..\..\packages\System.Reflection.4.3.0\lib\net462\System.Reflection.dll True @@ -209,6 +233,9 @@ ..\..\packages\System.Runtime.4.3.0\lib\net462\System.Runtime.dll True + + ..\..\packages\System.Runtime.CompilerServices.Unsafe.4.5.1\lib\netstandard2.0\System.Runtime.CompilerServices.Unsafe.dll + ..\..\packages\System.Runtime.Extensions.4.3.0\lib\net462\System.Runtime.Extensions.dll True @@ -348,6 +375,8 @@ + + diff --git a/src/AnalysisPrograms/App.config b/src/AnalysisPrograms/App.config index fb9a1b63d..050a604a0 100644 --- a/src/AnalysisPrograms/App.config +++ b/src/AnalysisPrograms/App.config @@ -86,6 +86,10 @@ + + + + \ No newline at end of file diff --git a/src/AnalysisPrograms/ConcatenateIndexFiles.cs b/src/AnalysisPrograms/ConcatenateIndexFiles.cs index 41c7a3abd..95ec79356 100644 --- a/src/AnalysisPrograms/ConcatenateIndexFiles.cs +++ b/src/AnalysisPrograms/ConcatenateIndexFiles.cs @@ -173,7 +173,7 @@ public static void Execute(Arguments arguments) Log.Warn(@" ! ! THIS IS A BETA COMMAND. -! It generally works but only for very narrow scenarios. Your milage *will* vary. +! It generally works but only for very narrow scenarios. Your mileage *will* vary. !"); if (arguments.InputDataDirectory != null) @@ -205,10 +205,10 @@ public static void Execute(Arguments arguments) var subDirectories = LdSpectrogramStitching.GetSubDirectoriesForSiteData(inputDirs, arguments.DirectoryFilter); if (subDirectories.Length == 0) { - LoggedConsole.WriteErrorLine("\n\n#WARNING from method ConcatenateIndexFiles.Execute():"); + LoggedConsole.WriteErrorLine("\n\n#Error from method ConcatenateIndexFiles.Execute():"); LoggedConsole.WriteErrorLine(" Subdirectory Count with given filter = ZERO"); LoggedConsole.WriteErrorLine(" RETURNING EMPTY HANDED!"); - return; + throw new MissingDataException("Could not find any sub directories from input directories:" + inputDirs.FormatList()); } // 2. PATTERN SEARCH FOR SUMMARY INDEX FILES. @@ -219,10 +219,10 @@ public static void Execute(Arguments arguments) if (csvFiles.Length == 0) { - LoggedConsole.WriteErrorLine("\n\nWARNING from method ConcatenateIndexFiles.Execute():"); + LoggedConsole.WriteErrorLine("\n\nError from method ConcatenateIndexFiles.Execute():"); LoggedConsole.WriteErrorLine(" No SUMMARY index files were found."); LoggedConsole.WriteErrorLine(" RETURNING EMPTY HANDED!"); - return; + throw new MissingDataException($"Could not find any files matching `{pattern}` in:" + subDirectories.FormatList()); } // Sort the files by date and return as a dictionary: sortedDictionaryOfDatesAndFiles diff --git a/src/AnalysisPrograms/Production/Exceptions.cs b/src/AnalysisPrograms/Production/Exceptions.cs index 5103186ce..880288084 100644 --- a/src/AnalysisPrograms/Production/Exceptions.cs +++ b/src/AnalysisPrograms/Production/Exceptions.cs @@ -9,11 +9,12 @@ namespace AnalysisPrograms.Production using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.IO; - + using System.Linq; using Acoustics.Shared; using Acoustics.Shared.ConfigFile; using AnalysisBase; + using AnalysisPrograms.Production.Arguments; using McMaster.Extensions.CommandLineUtils; public static class ExceptionLookup @@ -90,7 +91,11 @@ private static Dictionary CreateExceptionMap() }, { typeof(InvalidDataSetException), - new ExceptionStyle() {ErrorCode = 106, PrintUsage = false } + new ExceptionStyle() { ErrorCode = 106, PrintUsage = false } + }, + { + typeof(MissingDataException), + new ExceptionStyle() { ErrorCode = 107, PrintUsage = false } }, { typeof(AnalysisOptionDevilException), @@ -152,7 +157,8 @@ public int ErrorCode public class CommandLineArgumentException : Exception { - public CommandLineArgumentException(string message) : base(message) + public CommandLineArgumentException(string message) + : base(message) { } } @@ -185,6 +191,14 @@ public InvalidAudioChannelException(string message) } } + public class MissingDataException : Exception + { + public MissingDataException(string message) + : base(message) + { + } + } + public class AnalysisOptionDevilException : Exception { } diff --git a/src/AnalysisPrograms/RibbonPlots/RibbonPlot.Arguments.cs b/src/AnalysisPrograms/RibbonPlots/RibbonPlot.Arguments.cs new file mode 100644 index 000000000..77d51072d --- /dev/null +++ b/src/AnalysisPrograms/RibbonPlots/RibbonPlot.Arguments.cs @@ -0,0 +1,59 @@ +// +// All code in this file and all associated files are the copyright and property of the QUT Ecoacoustics Research Group (formerly MQUTeR, and formerly QUT Bioacoustics Research Group). +// + +namespace AnalysisPrograms.RibbonPlots +{ + using System; + using System.IO; + using System.Threading.Tasks; + using AnalysisPrograms.Production.Arguments; + using AnalysisPrograms.Production.Validation; + using McMaster.Extensions.CommandLineUtils; + + public partial class RibbonPlot + { + public const string CommandName = "DrawRibbonPlots"; + + public const string AdditionalNotes = + "This command treats all found ribbon plots as belonging to the same site. " + + "Thus files with duplicate dates are not permitted."; + + [Command( + CommandName, + Description = "Combines ribbon plots together into a stacked chart", + ExtendedHelpText = AdditionalNotes)] + public class Arguments : SubCommandBase + { + [Argument( + 0, + Description = "One or more directories where that contain ribbon FCS files.")] + public DirectoryInfo[] SourceDirectories { get; set; } + + [Option( + CommandOptionType.SingleValue, + Description = "Directory where the output ribbon plot is saved.")] + [DirectoryExistsOrCreate(createIfNotExists: true)] + [LegalFilePath] + public DirectoryInfo OutputDirectory { get; set; } + + [Option( + CommandOptionType.SingleValue, + Description = + "TimeSpan offset hint required if file names do not contain time zone info. NO DEFAULT IS SET", + ShortName = "z")] + public TimeSpan? TimeSpanOffsetHint { get; set; } + + [Option( + CommandOptionType.SingleValue, + Description = + "Changes when `Midnight` occurs. Defaults to `00:00`. If you want ribbons that span across the night, then set `Midnight` to `12:00`")] + public TimeSpan? Midnight { get; set; } = TimeSpan.Zero; + + public override Task Execute(CommandLineApplication app) + { + return RibbonPlot.Execute(this); + } + } + } +} \ No newline at end of file diff --git a/src/AnalysisPrograms/RibbonPlots/RibbonPlot.Entry.cs b/src/AnalysisPrograms/RibbonPlots/RibbonPlot.Entry.cs new file mode 100644 index 000000000..038cdbed1 --- /dev/null +++ b/src/AnalysisPrograms/RibbonPlots/RibbonPlot.Entry.cs @@ -0,0 +1,228 @@ +// +// All code in this file and all associated files are the copyright and property of the QUT Ecoacoustics Research Group (formerly MQUTeR, and formerly QUT Bioacoustics Research Group). +// + +namespace AnalysisPrograms.RibbonPlots +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Security.Cryptography; + using System.Text; + using System.Threading.Tasks; + using Acoustics.Shared; + using AnalysisPrograms.Production; + using AudioAnalysisTools.Indices; + using log4net; + using SixLabors.Fonts; + using SixLabors.ImageSharp; + using SixLabors.ImageSharp.PixelFormats; + using SixLabors.ImageSharp.Processing; + using SixLabors.ImageSharp.Processing.Processors; + using SixLabors.Primitives; + using SixLabors.Shapes; + + /// + /// Draws ribbon plots from ribbon FCS images. + /// + public partial class RibbonPlot + { + private static readonly ILog Log = LogManager.GetLogger(nameof(RibbonPlot)); + + public static async Task Execute(Arguments arguments) + { + if (arguments.SourceDirectories.IsNullOrEmpty()) + { + throw new CommandLineArgumentException( + $"{nameof(arguments.SourceDirectories)} is null or empty - please provide at least one source directory"); + } + + var doNotExist = arguments.SourceDirectories.Where(x => !x.Exists); + if (doNotExist.Any()) + { + throw new CommandLineArgumentException( + $"The following directories given to {nameof(arguments.SourceDirectories)} do not exist: " + doNotExist.FormatList()); + } + + if (arguments.OutputDirectory == null) + { + arguments.OutputDirectory = arguments.SourceDirectories.First(); + Log.Warn( + $"{nameof(arguments.OutputDirectory)} was not provided and was automatically set to source directory {arguments.OutputDirectory}"); + } + + if (arguments.Midnight == null || arguments.Midnight == TimeSpan.FromHours(24)) + { + arguments.Midnight = TimeSpan.Zero; + Log.Debug($"{nameof(arguments.Midnight)} was reset to {arguments.Midnight}"); + } + + if (arguments.Midnight < TimeSpan.Zero || arguments.Midnight > TimeSpan.FromHours(24)) + { + throw new InvalidStartOrEndException($"{nameof(arguments.Midnight)} cannot be less than `00:00` or greater than `24:00`"); + } + + LoggedConsole.Write("Begin scanning directories"); + + var allIndexFiles = arguments.SourceDirectories.SelectMany(IndexGenerationData.FindAll); + + if (allIndexFiles.IsNullOrEmpty()) + { + throw new MissingDataException($"Could not find `{IndexGenerationData.FileNameFragment}` files in:" + arguments.SourceDirectories.FormatList()); + } + + Log.Debug("Checking files have dates"); + var indexGenerationDatas = allIndexFiles.Select(IndexGenerationData.Load); + var datedIndices = FileDateHelpers.FilterObjectsForDates( + indexGenerationDatas, + x => x.Source, + y => y.RecordingStartDate, + arguments.TimeSpanOffsetHint); + + LoggedConsole.WriteLine($"{datedIndices.Count} index generation data files were loaded"); + if (datedIndices.Count == 0) + { + throw new MissingDataException("No index generation files had dates, cannot proceed"); + } + + // now find the ribbon plots for these images - there are typically two color maps per index generation + var datesMappedToColorMaps = new Dictionary>(2); + foreach (var (date, indexData) in datedIndices) + { + Add(indexData.LongDurationSpectrogramConfig.ColorMap1); + Add(indexData.LongDurationSpectrogramConfig.ColorMap2); + + void Add(string colorMap) + { + if (!datesMappedToColorMaps.ContainsKey(colorMap)) + { + datesMappedToColorMaps.Add(colorMap, new Dictionary(datedIndices.Count)); + } + + // try to find the associated ribbon + var ribbonFile = indexData.Source?.Directory?.EnumerateFiles("*" + colorMap + "*").FirstOrDefault(); + if (ribbonFile == null) + { + Log.Warn($"Did not find expected ribbon file for color map {colorMap} in directory {indexData.Source?.Directory}." + + "This can happen if the ribbon is missing or if more than one file matches the color map."); + } + + datesMappedToColorMaps[colorMap].Add(date, ribbonFile); + } + } + + // get the min and max dates and other things + var stats = new RibbonPlotStats(datedIndices, arguments.Midnight.Value); + + Log.Debug($"Files found between {stats.Min:R} and {stats.Max:R}, rendering between {stats.Start:R} and {stats.End:R}, in {stats.Buckets} buckets"); + + foreach (var (colorMap, ribbons) in datesMappedToColorMaps) + { + CreateRibbonPlot(datedIndices, ribbons, stats); + } + + return ExceptionLookup.Ok; + } + + + private static Image CreateRibbonPlot(SortedDictionary data, + Dictionary ribbons, RibbonPlotStats stats) + { + const int Padding = 5; + const int HorizontalPadding = 10; + const int LabelWidth = 100; + + // read random ribbon in to get height - assumes all ribbons are same height + int ribbonHeight; + var someRibbon = ribbons.First(); + int estimatedWidth = (int)Math.Round(TimeSpan.FromHours(24).Divide(data[someRibbon.Key].IndexCalculationDuration), MidpointRounding.ToEven); + + using (var testImage = Image.Load(someRibbon.Value.FullName)) + { + ribbonHeight = testImage.Height; + } + + var finalHeight = (Padding + ribbonHeight) * stats.Buckets; + var ribbonLeft = HorizontalPadding + LabelWidth + HorizontalPadding; + var finalWidth = ribbonLeft + estimatedWidth + HorizontalPadding; + + // create a new image! + var image = new Image(Configuration.Default, finalWidth, finalHeight, NamedColors.White); + + // draw labels and voids + int y = Padding; + var day = stats.Start; + var scaledFont = new Font(SystemFonts.Find("Arial"), ribbonHeight); + var textGraphics = new TextGraphicsOptions(true) + { HorizontalAlignment = HorizontalAlignment.Left, VerticalAlignment = VerticalAlignment.Top }; + var textColor = NamedColors.Black; + var voidColor = NamedColors.Gray; + var firstOffset = stats.Start.Offset; + for (var b = 0; b < stats.Buckets; b++) + { + // get label + var dateLabel = day.ToOffset(firstOffset).ToString(AppConfigHelper.RenderedDateFormatShort); + + image.Mutate(Operation); + + void Operation(IImageProcessingContext context) + { + // draw label + context.DrawText(textGraphics, dateLabel, scaledFont, textColor, new PointF(HorizontalPadding, y)); + + // draw void + var @void = new RectangularPolygon(ribbonLeft, y, estimatedWidth, ribbonHeight); + context.Fill(voidColor, @void); + } + } + + // copy images in + foreach (var (date, ribbon) in ribbons) + { + // determine ribbon start + end + var ribbonStart = + + var top = _ + var left = ribbonLeft + _; + using (var source = Image.Load(ribbon.FullName)) + { + image.Mutate(x => x.DrawImage()); + } + } + + + return image; + + + } + + private class RibbonPlotStats + { + public RibbonPlotStats(SortedDictionary datedIndices, TimeSpan midnight) + { + this.Min = datedIndices.Keys.First(); + this.First = datedIndices[this.Min]; + this.Max = datedIndices.Keys.Last(); + this.Last = datedIndices[this.Max]; + this.Start = this.Min.Floor(midnight); + this.End = (this.Max + this.Last.RecordingDuration).Ceiling(midnight); + this.Buckets = (int)Math.Ceiling((this.End - this.Start).Divide(TimeSpan.FromHours(24))); + } + + public DateTimeOffset Min { get; } + + public IndexGenerationData First { get; } + + public DateTimeOffset Max { get; } + + public IndexGenerationData Last { get; } + + public DateTimeOffset Start { get; } + + public DateTimeOffset End { get; } + + public int Buckets { get; } + } + } +} diff --git a/src/AnalysisPrograms/packages.config b/src/AnalysisPrograms/packages.config index def108f68..4d8c7820b 100644 --- a/src/AnalysisPrograms/packages.config +++ b/src/AnalysisPrograms/packages.config @@ -28,6 +28,11 @@ + + + + + @@ -37,6 +42,7 @@ + @@ -54,15 +60,18 @@ + + + diff --git a/src/AudioAnalysisTools/Indices/IndexGenerationData.cs b/src/AudioAnalysisTools/Indices/IndexGenerationData.cs index d36f48629..fe6f51fa3 100644 --- a/src/AudioAnalysisTools/Indices/IndexGenerationData.cs +++ b/src/AudioAnalysisTools/Indices/IndexGenerationData.cs @@ -7,18 +7,23 @@ namespace AudioAnalysisTools.Indices { using System; + using System.Collections.Generic; + using System.IO; using System.Linq; using Acoustics.Shared; - using LongDurationSpectrograms; - + using AudioAnalysisTools.LongDurationSpectrograms; + using log4net; + using Newtonsoft.Json; using Zio; public class IndexGenerationData { public const string FileNameFragment = "IndexGenerationData"; + private static readonly ILog Log = LogManager.GetLogger(nameof(IndexGenerationData)); + /// - /// Gets or sets the configuration options used to draw long duration spectrograms + /// Gets or sets the configuration options used to draw long duration spectrograms. /// public LdSpectrogramConfig LongDurationSpectrogramConfig { get; set; } @@ -77,6 +82,9 @@ public class IndexGenerationData public TimeSpan RecordingDuration { get; set; } + [JsonIgnore] + public FileInfo Source { get; private set; } + /// /// Returns the index generation data from file in passed directory. /// @@ -85,10 +93,24 @@ public static IndexGenerationData GetIndexGenerationData(DirectoryEntry director return Json.Deserialize(FindFile(directory)); } + public static IndexGenerationData Load(FileInfo info) + { + var indexGenerationData = Json.Deserialize(info); + indexGenerationData.Source = info; + return indexGenerationData; + } + public static FileEntry FindFile(DirectoryEntry directory) { const string pattern = "*" + FileNameFragment + "*"; return directory.EnumerateFiles(pattern).Single(); } + + public static IEnumerable FindAll(DirectoryInfo directory) + { + const string pattern = "*" + FileNameFragment + "*"; + + return directory.EnumerateFiles(pattern, SearchOption.AllDirectories); + } } }