/// <summary> /// Joins summary indices csv files together. /// This method merges ALL the passed files of acoustic indices /// It is assumed you are concatenating a sequence of consecutive short recordings. /// </summary> public static List <SummaryIndexValues> ConcatenateAllSummaryIndexFiles( FileInfo[] summaryIndexFiles, DirectoryInfo opDir, IndexGenerationData indexGenerationData, string outputFileBaseName) { var indexResolution = indexGenerationData.IndexCalculationDuration; var summaryIndices = IndexMatrices.ConcatenateSummaryIndexFilesWithTimeCheck(summaryIndexFiles, indexResolution); if (summaryIndices.Count == 0) { LoggedConsole.WriteErrorLine("WARNING: LDSpectrogramStitching.ConcatenateAllSummaryIndexFiles(): Empty List of SUMMARY indices returned!"); return(null); } // check length of data and make adjustments if required. // NOTHING done with this info at the moment. Could be used to truncate data to 24 hours. //int totalRowMinutes = (int)Math.Round(summaryIndices.Count() * indexResolution.TotalMinutes); // write out the list of data file names to JSON file. var arrayOfFileNames = summaryIndices.Select(x => x.FileName).ToArray(); var path = FilenameHelpers.AnalysisResultPath(opDir, outputFileBaseName, "FileNames", "json"); Json.Serialise(new FileInfo(path), arrayOfFileNames); return(summaryIndices); }
/// <summary> /// This is cut down version of the method of same name in LDSpectrogramRGB.cs. /// </summary> /// <param name="ldSpectrogramConfig">config for ldfc spectrogram.</param> /// <param name="outputDirectory">outputDirectory.</param> /// <param name="indexGenerationData">indexGenerationData.</param> /// <param name="basename">stem name of the original recording.</param> /// <param name="indexSpectrograms">Optional spectra to pass in. If specified the spectra will not be loaded from disk!.</param> private static string DrawSpectrogramsFromSpectralIndices( LdSpectrogramConfig ldSpectrogramConfig, DirectoryInfo outputDirectory, IndexGenerationData indexGenerationData, string basename, Dictionary <string, double[, ]> indexSpectrograms = null) { string colorMap1 = ldSpectrogramConfig.ColorMap1; // SpectrogramConstants.RGBMap_ACI_ENT_EVN; string colorMap2 = ldSpectrogramConfig.ColorMap2; // SpectrogramConstants.RGBMap_BGN_PMN_OSC; double blueEnhanceParameter = ldSpectrogramConfig.BlueEnhanceParameter.Value; var cs1 = new LDSpectrogramRGB(ldSpectrogramConfig, indexGenerationData, colorMap1); string fileStem = basename; cs1.FileName = fileStem; // calculate start time by combining DatetimeOffset with minute offset. cs1.StartOffset = indexGenerationData.AnalysisStartOffset; if (indexGenerationData.RecordingStartDate.HasValue) { DateTimeOffset dto = (DateTimeOffset)indexGenerationData.RecordingStartDate; cs1.RecordingStartDate = dto; if (dto != null) { cs1.StartOffset = dto.TimeOfDay + cs1.StartOffset; } } var indexProperties = IndexCalculateSixOnly.GetIndexProperties(); cs1.SetSpectralIndexProperties(indexProperties); // Load the Index Spectrograms into a Dictionary cs1.LoadSpectrogramDictionary(indexSpectrograms); if (cs1.GetCountOfSpectrogramMatrices() == 0) { Log.Error("No spectrogram matrices in the dictionary. Spectrogram files do not exist?"); throw new InvalidOperationException("Cannot find spectrogram matrix files"); } // draw all available gray scale index spectrograms. var keys = indexProperties.Keys.ToArray(); cs1.DrawGreyScaleSpectrograms(outputDirectory, fileStem, keys); // create two false-color spectrogram images var image1NoChrome = cs1.DrawFalseColorSpectrogramChromeless(cs1.ColorMode, colorMap1, blueEnhanceParameter); var image2NoChrome = cs1.DrawFalseColorSpectrogramChromeless(cs1.ColorMode, colorMap2, blueEnhanceParameter); var spacer = new Image <Rgb24>(image1NoChrome.Width, 10); var imageList = new[] { image1NoChrome, spacer, image2NoChrome, spacer }; Image image3 = ImageTools.CombineImagesVertically(imageList); var outputPath = FilenameHelpers.AnalysisResultPath(outputDirectory, fileStem, "2Maps", "png"); image3.Save(outputPath); return(outputPath); }
/// <summary> /// Writes a list of erroneous segment properties to file. /// </summary> /// <param name="errors">list of erroneous segments.</param> /// <param name="outputDirectory">directory in which json file to be written.</param> /// <param name="fileStem">name of json file.</param> public static void WriteErrorsToFile(List <GapsAndJoins> errors, DirectoryInfo outputDirectory, string fileStem) { // write info to file if (errors.Count == 0) { return; } string path = FilenameHelpers.AnalysisResultPath(outputDirectory, fileStem, ErroneousIndexSegmentsFilenameFragment, "json"); // ReSharper disable once RedundantTypeArgumentsOfMethod Json.Serialise <List <GapsAndJoins> >(new FileInfo(path), errors); }
private static FileInfo SaveResults <T>(DirectoryInfo outputDirectory, string resultFilenameBase, string analysisTag, Action <FileInfo, IEnumerable <T> > serialiseFunc, IEnumerable <T> results) { if (results == null) { Log.Debug("No results returned... file not written:" + resultFilenameBase + ReportFileExt); return(null); } var reportFileInfo = FilenameHelpers.AnalysisResultPath(outputDirectory, resultFilenameBase, analysisTag, ReportFileExt).ToFileInfo(); serialiseFunc(reportFileInfo, results); return(reportFileInfo); }
public static List <FileInfo> WriteSpectrumIndicesFilesCustom(DirectoryInfo destination, string fileNameBase, IEnumerable <SpectralIndexBase> results) { var selectors = results.First().GetSelectors(); var spectralIndexFiles = new List <FileInfo>(selectors.Count); foreach (var kvp in selectors) { // write spectrogram to disk as CSV file var filename = FilenameHelpers.AnalysisResultPath(destination, fileNameBase, TowseyAcoustic + "." + kvp.Key, "csv").ToFileInfo(); spectralIndexFiles.Add(filename); Csv.WriteMatrixToCsv(filename, results, kvp.Value); } return(spectralIndexFiles); }
public override List <FileInfo> WriteSpectrumIndicesFiles(DirectoryInfo destination, string fileNameBase, IEnumerable <SpectralIndexBase> results) { // Group results based on configuration Dictionary <IndexCalculateConfig, List <SpectralIndexBase> > dict = GroupResultsOnConfiguration(results); var spectralIndexFiles = new List <FileInfo>(); // For each group and for each selector?? create a csv file foreach (var configGroup in dict) { var groupResults = configGroup.Value; var selectors = groupResults.First().GetSelectors(); // Get the values of the band configuration, since they all have same configuration, just get the first item var config = (SpectralIndexValues)groupResults.First(); string minBandWidth = config.Configuration.MinBandWidth.ToString(); string maxBandWidth = config.Configuration.MaxBandWidth.ToString(); string mel; string melScale; if (config.Configuration.MelScale != 0) { mel = "Mel"; melScale = config.Configuration.MelScale.ToString(); } else { mel = "Standard"; melScale = 0.ToString(); } string fftWindow = config.Configuration.FrameLength.ToString(); foreach (var kvp in selectors) { // write spectrogram to disk as CSV file var filename = FilenameHelpers.AnalysisResultPath(destination, fileNameBase, this.Identifier + "." + kvp.Key, "csv", minBandWidth, maxBandWidth, mel, melScale, "FftWindow", fftWindow).ToFileInfo(); spectralIndexFiles.Add(filename); Csv.WriteMatrixToCsv(filename, groupResults, kvp.Value); } } return(spectralIndexFiles); }
public static void DrawSummaryIndexFiles( Dictionary <string, double[]> dictionaryOfCsvColumns, IndexGenerationData indexGenerationData, FileInfo indexPropertiesConfigFileInfo, DirectoryInfo opDir, SiteDescription siteDescription, FileInfo sunriseDatafile = null, List <GapsAndJoins> erroneousSegments = null, // info if have fatal errors i.e. no signal bool verbose = false) { var dto = (DateTimeOffset)indexGenerationData.RecordingStartDate; string dateString = $"{dto.Year}{dto.Month:D2}{dto.Day:D2}"; string opFileStem = $"{siteDescription.SiteName}_{dateString}"; // Calculate the index distribution statistics and write to a json file. Also save as png image var indexDistributions = IndexDistributions.WriteSummaryIndexDistributionStatistics(dictionaryOfCsvColumns, opDir, opFileStem); var start = ((DateTimeOffset)indexGenerationData.RecordingStartDate).TimeOfDay; string startTime = $"{start.Hours:d2}{start.Minutes:d2}h"; if (start.Hours == 0 && start.Minutes == 0) { startTime = "midnight"; } string titletext = $"SOURCE: \"{opFileStem}\". Starts at {startTime} {Meta.OrganizationTag}"; Bitmap tracksImage = IndexDisplay.DrawImageOfSummaryIndices( IndexProperties.GetIndexProperties(indexPropertiesConfigFileInfo), dictionaryOfCsvColumns, titletext, indexGenerationData.IndexCalculationDuration, indexGenerationData.RecordingStartDate, sunriseDatafile, erroneousSegments, verbose); var imagePath = FilenameHelpers.AnalysisResultPath(opDir, opFileStem, SummaryIndicesStr, ImgFileExt); tracksImage.Save(imagePath); }
public override List <FileInfo> WriteSpectrumIndicesFiles(DirectoryInfo destination, string fileNameBase, IEnumerable <SpectralIndexBase> results) { if (!results.Any()) { return(null); } var selectors = results.First().GetSelectors(); var spectralIndexFiles = new List <FileInfo>(selectors.Count); foreach (var kvp in selectors) { // write spectrum to disk as CSV file var filename = FilenameHelpers.AnalysisResultPath(destination, fileNameBase, this.Identifier + "." + kvp.Key, "csv").ToFileInfo(); spectralIndexFiles.Add(filename); Csv.WriteMatrixToCsv(filename, results, kvp.Value); } return(spectralIndexFiles); }
public override List <FileInfo> WriteSpectrumIndicesFiles(DirectoryInfo destination, string fileNameBase, IEnumerable <SpectralIndexBase> results) { //get selectors and removed unwanted because these indices were never calculated. var spectralIndexBases = results.ToList(); var selectors = spectralIndexBases.First().GetSelectors(); // TODO: REMOVE unused index filter with new Spectral Indices child class foreach (var indexName in ContentSignatures.UnusedIndexNames) { selectors.Remove(indexName); } var spectralIndexFiles = new List <FileInfo>(selectors.Count); foreach (var kvp in selectors) { // write spectrogram to disk as CSV file var filename = FilenameHelpers.AnalysisResultPath(destination, fileNameBase, TowseyContentDescription + "." + kvp.Key, "csv").ToFileInfo(); spectralIndexFiles.Add(filename); Csv.WriteMatrixToCsv(filename, spectralIndexBases, kvp.Value); } return(spectralIndexFiles); }
private void DrawLongDurationSpectrogram( DirectoryInfo outputDirectory, string fileStem, Image <Rgb24> scoreTrack, IndexCalculateResult[] indexResults, AcousticIndices.AcousticIndicesConfig acousticIndicesConfig) { var dictionaryOfSpectra = indexResults.Select(icr => icr.SpectralIndexValues).ToArray().ToTwoDimensionalArray(SpectralIndexValues.CachedSelectors, TwoDimensionalArray.Rotate90ClockWise); FileInfo ipConfig = acousticIndicesConfig.IndexPropertiesConfig.ToFileInfo(); double hiResScale = acousticIndicesConfig.IndexCalculationDuration; TimeSpan hiResTimeScale = TimeSpan.FromSeconds(hiResScale); FileInfo spectrogramConfig = ConfigFile.Resolve(acousticIndicesConfig["SpectrogramConfig"]); // Assemble arguments for drawing the GRAY-SCALE and RIDGE SPECTROGRAMS var output = outputDirectory.Combine("SpectrogramImages"); var ldfcSpectrogramArguments = new DrawLongDurationSpectrograms.Arguments { // passed null for first InputDataDirectory on purpose: we don't want to read files off disk InputDataDirectory = null, OutputDirectory = output.FullName, FalseColourSpectrogramConfig = spectrogramConfig.FullName, IndexPropertiesConfig = ipConfig.FullName, ColourMap1 = "BGN-DMN-EVN", ColourMap2 = "RHZ-RVT-SPT", //R3D replaces PHN as new derived index TemporalScale = hiResTimeScale, }; bool saveRidgeSpectrograms = acousticIndicesConfig.GetBoolOrNull("SaveRidgeSpectrograms") ?? false; if (saveRidgeSpectrograms) { // 1: DRAW the coloured ridge spectrograms // passed null for first argument on purpose: we don't want to read files off disk var ridgeSpectrogram = DrawLongDurationSpectrograms.DrawRidgeSpectrograms(null, ipConfig, fileStem, hiResScale, dictionaryOfSpectra); //var opImages = new List<Image>(); //opImages.Add(ridgeSpectrogram); //opImages.Add(scoreTrackImage); // combine and save //Image opImage = ImageTools.CombineImagesVertically(opImages); var fileName = FilenameHelpers.AnalysisResultPath(output, fileStem, "Ridges", ".png"); //opImage.Save(fileName); ridgeSpectrogram.Save(fileName); } // if (saveRidgeSpectrograms) // 2. DRAW the aggregated GREY-SCALE SPECTROGRAMS of SPECTRAL INDICES Image <Rgb24> opImage; bool saveGrayScaleSpectrograms = acousticIndicesConfig.GetBoolOrNull("SaveGrayScaleSpectrograms") ?? false; if (saveGrayScaleSpectrograms) { opImage = DrawLongDurationSpectrograms.DrawGrayScaleSpectrograms(ldfcSpectrogramArguments, fileStem, hiResTimeScale, dictionaryOfSpectra); var fileName = FilenameHelpers.AnalysisResultPath(output, fileStem, "CombinedGreyScale", ".png"); opImage.Save(fileName); } // 3. DRAW False-colour Spectrograms bool saveTwoMapsSpectrograms = acousticIndicesConfig.GetBoolOrNull("SaveTwoMapsSpectrograms") ?? false; if (saveTwoMapsSpectrograms) { opImage = DrawLongDurationSpectrograms.DrawFalseColourSpectrograms(ldfcSpectrogramArguments, fileStem, dictionaryOfSpectra); var opImages = new [] { opImage, scoreTrack }; opImage = ImageTools.CombineImagesVertically(opImages); var fileName = FilenameHelpers.AnalysisResultPath(output, fileStem, "TwoMaps", ".png"); opImage.Save(fileName); } }
public static string GetSpectralImagePath(DirectoryInfo outputDirectory, string fileStem) { return(FilenameHelpers.AnalysisResultPath(outputDirectory, fileStem, SpectralIndexDistributionsFilenameFragment, "png")); }
public static string GetSpectralStatsPath(DirectoryInfo outputDirectory, string fileStem) { return(FilenameHelpers.AnalysisResultPath(outputDirectory, fileStem, SpectralIndexStatisticsFilenameFragment, "json")); }
/// <summary> /// 2. Analyses long audio recording (mp3 or wav) as per passed config file. Outputs an events.csv file AND an /// indices.csv file /// Signed off: Michael Towsey 4th December 2012 /// </summary> public static void Execute(Arguments arguments) { if (arguments == null) { throw new NoDeveloperMethodException(); } LoggedConsole.WriteLine("# PROCESS LONG RECORDING"); LoggedConsole.WriteLine("# DATE AND TIME: " + DateTime.Now); // 1. set up the necessary files var sourceAudio = arguments.Source; var configFile = arguments.Config.ToFileInfo(); var outputDirectory = arguments.Output; var tempFilesDirectory = arguments.TempDir; // if a temp dir is not given, use output dir as temp dir if (tempFilesDirectory == null) { Log.Warn("No temporary directory provided, using output directory"); tempFilesDirectory = outputDirectory; } // try an automatically find the config file if (configFile == null) { throw new FileNotFoundException("No config file argument provided"); } else if (!configFile.Exists) { Log.Warn($"Config file {configFile.FullName} not found... attempting to resolve config file"); // we use .ToString() here to get the original input string - Using fullname always produces an absolute path wrt to pwd... we don't want to prematurely make asusmptions: // e.g. We require a missing absolute path to fail... that wouldn't work with .Name // e.g. We require a relative path to try and resolve, using .FullName would fail the first absolute check inside ResolveConfigFile configFile = ConfigFile.Resolve(configFile.ToString(), Directory.GetCurrentDirectory().ToDirectoryInfo()); } if (arguments.StartOffset.HasValue ^ arguments.EndOffset.HasValue) { throw new InvalidStartOrEndException("If StartOffset or EndOffset is specified, then both must be specified"); } if (arguments.StartOffset.HasValue && arguments.EndOffset.HasValue && arguments.EndOffset.Value <= arguments.StartOffset.Value) { throw new InvalidStartOrEndException("Start offset must be less than end offset."); } LoggedConsole.WriteLine("# Recording file: " + sourceAudio.FullName); LoggedConsole.WriteLine("# Configuration file: " + configFile); LoggedConsole.WriteLine("# Output folder: " + outputDirectory); LoggedConsole.WriteLine("# Temp File Directory: " + tempFilesDirectory); // optionally copy logs / config to make results easier to understand // TODO: remove, see https://github.com/QutEcoacoustics/audio-analysis/issues/133 if (arguments.WhenExitCopyConfig || arguments.WhenExitCopyLog) { AppDomain.CurrentDomain.ProcessExit += (sender, args) => { Cleanup(arguments, configFile); }; } // 2. initialize the analyzer // 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. // Get analysis name: IAnalyser2 analyzer = FindAndCheckAnalyzer <IAnalyser2>(arguments.AnalysisIdentifier, configFile.Name); // 2. get the analysis config AnalyzerConfig configuration = analyzer.ParseConfig(configFile); SaveBehavior saveIntermediateWavFiles = configuration.SaveIntermediateWavFiles; bool saveIntermediateDataFiles = configuration.SaveIntermediateCsvFiles; SaveBehavior saveSonogramsImages = configuration.SaveSonogramImages; bool filenameDate = configuration.RequireDateInFilename; if (configuration[AnalysisKeys.AnalysisName].IsNotWhitespace()) { Log.Warn("Your config file has `AnalysisName` set - this property is deprecated and ignored"); } // AT 2018-02: changed logic so default index properties loaded if not provided FileInfo indicesPropertiesConfig = IndexProperties.Find(configuration, configFile); if (indicesPropertiesConfig == null || !indicesPropertiesConfig.Exists) { Log.Warn("IndexProperties config can not be found! Loading a default"); indicesPropertiesConfig = ConfigFile.Default <Dictionary <string, IndexProperties> >(); } LoggedConsole.WriteLine("# IndexProperties Cfg: " + indicesPropertiesConfig.FullName); // min score for an acceptable event Log.Info("Minimum event threshold has been set to " + configuration.EventThreshold); FileSegment.FileDateBehavior defaultBehavior = FileSegment.FileDateBehavior.Try; if (filenameDate) { if (!FileDateHelpers.FileNameContainsDateTime(sourceAudio.Name)) { throw new InvalidFileDateException( "When RequireDateInFilename option is set, the filename of the source audio file must contain " + "a valid AND UNAMBIGUOUS date. Such a date was not able to be parsed."); } defaultBehavior = FileSegment.FileDateBehavior.Required; } // 3. initilize AnalysisCoordinator class that will do the analysis var analysisCoordinator = new AnalysisCoordinator( new LocalSourcePreparer(), saveIntermediateWavFiles, false, arguments.Parallel); // 4. get the segment of audio to be analysed // if tiling output, specify that FileSegment needs to be able to read the date var fileSegment = new FileSegment(sourceAudio, arguments.AlignToMinute, null, defaultBehavior); var bothOffsetsProvided = arguments.StartOffset.HasValue && arguments.EndOffset.HasValue; if (bothOffsetsProvided) { fileSegment.SegmentStartOffset = TimeSpan.FromSeconds(arguments.StartOffset.Value); fileSegment.SegmentEndOffset = TimeSpan.FromSeconds(arguments.EndOffset.Value); } else { Log.Debug("Neither start nor end segment offsets provided. Therefore both were ignored."); } // 6. initialize the analysis settings object var analysisSettings = analyzer.DefaultSettings; analysisSettings.ConfigFile = configFile; analysisSettings.Configuration = configuration; analysisSettings.AnalysisOutputDirectory = outputDirectory; analysisSettings.AnalysisTempDirectory = tempFilesDirectory; analysisSettings.AnalysisDataSaveBehavior = saveIntermediateDataFiles; analysisSettings.AnalysisImageSaveBehavior = saveSonogramsImages; analysisSettings.AnalysisChannelSelection = arguments.Channels; analysisSettings.AnalysisMixDownToMono = arguments.MixDownToMono; var segmentDuration = configuration.SegmentDuration?.Seconds(); if (!segmentDuration.HasValue) { segmentDuration = analysisSettings.AnalysisMaxSegmentDuration ?? TimeSpan.FromMinutes(1); Log.Warn( $"Can't read `{nameof(AnalyzerConfig.SegmentDuration)}` from config file. " + $"Default value of {segmentDuration} used)"); } analysisSettings.AnalysisMaxSegmentDuration = segmentDuration.Value; var segmentOverlap = configuration.SegmentOverlap?.Seconds(); if (!segmentOverlap.HasValue) { segmentOverlap = analysisSettings.SegmentOverlapDuration; Log.Warn( $"Can't read `{nameof(AnalyzerConfig.SegmentOverlap)}` from config file. " + $"Default value of {segmentOverlap} used)"); } analysisSettings.SegmentOverlapDuration = segmentOverlap.Value; // set target sample rate var resampleRate = configuration.ResampleRate; if (!resampleRate.HasValue) { resampleRate = analysisSettings.AnalysisTargetSampleRate ?? AppConfigHelper.DefaultTargetSampleRate; Log.Warn( $"Can't read {nameof(configuration.ResampleRate)} from config file. " + $"Default value of {resampleRate} used)"); } analysisSettings.AnalysisTargetSampleRate = resampleRate; Log.Info( $"{nameof(configuration.SegmentDuration)}={segmentDuration}, " + $"{nameof(configuration.SegmentOverlap)}={segmentOverlap}, " + $"{nameof(configuration.ResampleRate)}={resampleRate}"); // 7. ####################################### DO THE ANALYSIS ################################### LoggedConsole.WriteLine("START ANALYSIS ..."); var analyserResults = analysisCoordinator.Run(fileSegment, analyzer, analysisSettings); // ############################################################################################## // 8. PROCESS THE RESULTS LoggedConsole.WriteLine(string.Empty); LoggedConsole.WriteLine("START PROCESSING RESULTS ..."); if (analyserResults == null) { LoggedConsole.WriteErrorLine("###################################################\n"); LoggedConsole.WriteErrorLine("The Analysis Run Coordinator has returned a null result."); LoggedConsole.WriteErrorLine("###################################################\n"); throw new AnalysisOptionDevilException(); } // Merge and correct main result types EventBase[] mergedEventResults = ResultsTools.MergeResults(analyserResults, ar => ar.Events, ResultsTools.CorrectEvent); SummaryIndexBase[] mergedIndicesResults = ResultsTools.MergeResults(analyserResults, ar => ar.SummaryIndices, ResultsTools.CorrectSummaryIndex); SpectralIndexBase[] mergedSpectralIndexResults = ResultsTools.MergeResults(analyserResults, ar => ar.SpectralIndices, ResultsTools.CorrectSpectrumIndex); // not an exceptional state, do not throw exception if (mergedEventResults != null && mergedEventResults.Length == 0) { LoggedConsole.WriteWarnLine("The analysis produced no EVENTS (mergedResults had zero count)"); } if (mergedIndicesResults != null && mergedIndicesResults.Length == 0) { LoggedConsole.WriteWarnLine("The analysis produced no Summary INDICES (mergedResults had zero count)"); } if (mergedSpectralIndexResults != null && mergedSpectralIndexResults.Length == 0) { LoggedConsole.WriteWarnLine("The analysis produced no Spectral INDICES (merged results had zero count)"); } // 9. CREATE SUMMARY INDICES IF NECESSARY (FROM EVENTS) #if DEBUG // get the duration of the original source audio file - need this to convert Events datatable to Indices Datatable var audioUtility = new MasterAudioUtility(tempFilesDirectory); var mimeType = MediaTypes.GetMediaType(sourceAudio.Extension); var sourceInfo = audioUtility.Info(sourceAudio); // updated by reference all the way down in LocalSourcePreparer Debug.Assert(fileSegment.TargetFileDuration == sourceInfo.Duration); #endif var duration = fileSegment.TargetFileDuration.Value; ResultsTools.ConvertEventsToIndices( analyzer, mergedEventResults, ref mergedIndicesResults, duration, configuration.EventThreshold); int eventsCount = mergedEventResults?.Length ?? 0; int numberOfRowsOfIndices = mergedIndicesResults?.Length ?? 0; // 10. Allow analysers to post-process // TODO: remove results directory if possible var instanceOutputDirectory = AnalysisCoordinator.GetNamedDirectory(analysisSettings.AnalysisOutputDirectory, analyzer); // 11. IMPORTANT - this is where IAnalyser2's post processor gets called. // Produces all spectrograms and images of SPECTRAL INDICES. // Long duration spectrograms are drawn IFF analysis type is Towsey.Acoustic analyzer.SummariseResults(analysisSettings, fileSegment, mergedEventResults, mergedIndicesResults, mergedSpectralIndexResults, analyserResults); // 12. SAVE THE RESULTS string fileNameBase = Path.GetFileNameWithoutExtension(sourceAudio.Name); var eventsFile = ResultsTools.SaveEvents(analyzer, fileNameBase, instanceOutputDirectory, mergedEventResults); var indicesFile = ResultsTools.SaveSummaryIndices(analyzer, fileNameBase, instanceOutputDirectory, mergedIndicesResults); var spectraFile = ResultsTools.SaveSpectralIndices(analyzer, fileNameBase, instanceOutputDirectory, mergedSpectralIndexResults); // 13. THIS IS WHERE SUMMARY INDICES ARE PROCESSED // Convert summary indices to black and white tracks image if (mergedIndicesResults == null) { Log.Info("No summary indices produced"); } else { if (indicesPropertiesConfig == null || !indicesPropertiesConfig.Exists) { throw new InvalidOperationException("Cannot process indices without an index configuration file, the file could not be found!"); } // this arbitrary amount of data. if (mergedIndicesResults.Length > 5000) { Log.Warn("Summary Indices Image not able to be drawn - there are too many indices to render"); } else { var basename = Path.GetFileNameWithoutExtension(fileNameBase); string imageTitle = $"SOURCE:{basename}, {Meta.OrganizationTag}; "; // Draw Tracks-Image of Summary indices // set time scale resolution for drawing of summary index tracks TimeSpan timeScale = TimeSpan.FromSeconds(0.1); Bitmap tracksImage = IndexDisplay.DrawImageOfSummaryIndices( IndexProperties.GetIndexProperties(indicesPropertiesConfig), indicesFile, imageTitle, timeScale, fileSegment.TargetFileStartDate); var imagePath = FilenameHelpers.AnalysisResultPath(instanceOutputDirectory, basename, "SummaryIndices", ImageFileExt); tracksImage.Save(imagePath); } } // 14. wrap up, write stats LoggedConsole.WriteLine("INDICES CSV file(s) = " + (indicesFile?.Name ?? "<<No indices result, no file!>>")); LoggedConsole.WriteLine("\tNumber of rows (i.e. minutes) in CSV file of indices = " + numberOfRowsOfIndices); LoggedConsole.WriteLine(string.Empty); if (eventsFile == null) { LoggedConsole.WriteLine("An Events CSV file was NOT returned."); } else { LoggedConsole.WriteLine("EVENTS CSV file(s) = " + eventsFile.Name); LoggedConsole.WriteLine("\tNumber of events = " + eventsCount); } Log.Success($"Analysis Complete.\nSource={sourceAudio.Name}\nOutput={instanceOutputDirectory.FullName}"); }
public override void SummariseResults( AnalysisSettings analysisSettings, FileSegment inputFileSegment, EventBase[] events, SummaryIndexBase[] indices, SpectralIndexBase[] spectralIndices, AnalysisResult2[] results) { // below is example of how to access values in ContentDescription config file. //sampleRate = analysisSettings.Configuration.GetIntOrNull(AnalysisKeys.ResampleRate) ?? sampleRate; var cdConfiguration = (CdConfig)analysisSettings.Configuration; var ldSpectrogramConfig = cdConfiguration.LdSpectrogramConfig; //var cdConfigFile = analysisSettings.ConfigFile; //var configDirectory = cdConfigFile.DirectoryName ?? throw new ArgumentNullException(nameof(cdConfigFile), "Null value"); var sourceAudio = inputFileSegment.Source; string basename = Path.GetFileNameWithoutExtension(sourceAudio.Name); var resultsDirectory = AnalysisCoordinator.GetNamedDirectory(analysisSettings.AnalysisOutputDirectory, this); // check for null values - this was recommended by ReSharper! if (inputFileSegment.TargetFileDuration == null || inputFileSegment.TargetFileSampleRate == null) { throw new NullReferenceException(); } // output config data to disk so other analyzers can use the data, // Should contain data only - i.e. the configuration settings that generated these indices // this data can then be used by later analysis processes. var indexConfigData = new IndexGenerationData() { RecordingExtension = inputFileSegment.Source.Extension, RecordingBasename = basename, RecordingStartDate = inputFileSegment.TargetFileStartDate, RecordingDuration = inputFileSegment.TargetFileDuration.Value, SampleRateOriginal = inputFileSegment.TargetFileSampleRate.Value, SampleRateResampled = ContentSignatures.SampleRate, FrameLength = ContentSignatures.FrameSize, FrameStep = ContentSignatures.FrameSize, IndexCalculationDuration = TimeSpan.FromSeconds(ContentSignatures.IndexCalculationDurationInSeconds), BgNoiseNeighbourhood = TimeSpan.FromSeconds(5), // default value for content description AnalysisStartOffset = inputFileSegment.SegmentStartOffset ?? TimeSpan.Zero, MaximumSegmentDuration = analysisSettings.AnalysisMaxSegmentDuration, BackgroundFilterCoeff = SpectrogramConstants.BACKGROUND_FILTER_COEFF, LongDurationSpectrogramConfig = ldSpectrogramConfig, }; var icdPath = FilenameHelpers.AnalysisResultPath( resultsDirectory, basename, IndexGenerationData.FileNameFragment, "json"); Json.Serialise(icdPath.ToFileInfo(), indexConfigData); // gather spectra to form spectrograms. Assume same spectra in all analyzer results var dictionaryOfSpectra = spectralIndices.ToTwoDimensionalArray(SpectralIndexValuesForContentDescription.CachedSelectors, TwoDimensionalArray.Rotate90ClockWise); // Calculate the index distribution statistics and write to a json file. Also save as png image // The following method returns var indexDistributions =, but we have no use for them. IndexDistributions.WriteSpectralIndexDistributionStatistics(dictionaryOfSpectra, resultsDirectory, basename); // Draw ldfc spectrograms and return path to 2maps image. string ldfcSpectrogramPath = DrawSpectrogramsFromSpectralIndices( ldSpectrogramConfig, outputDirectory: resultsDirectory, indexGenerationData: indexConfigData, basename: basename, indexSpectrograms: dictionaryOfSpectra); // Gather the content description results into an array of DescriptionResult and then convert to dictionary var allContentDescriptionResults = results.Select(x => (DescriptionResult)x.MiscellaneousResults[nameof(DescriptionResult)]); var contentDictionary = DataProcessing.ConvertResultsToDictionaryOfArrays(allContentDescriptionResults.ToList()); // Write the results to a csv file var filePath = Path.Combine(resultsDirectory.FullName, "AcousticSignatures.csv"); // TODO: fix this so it writes header and a column of content description values. //Csv.WriteToCsv(new FileInfo(filePath), contentDictionary); FileTools.WriteDictionaryAsCsvFile(contentDictionary, filePath); // prepare graphical plots of the acoustic signatures. var contentPlots = GetPlots(contentDictionary); var images = GraphsAndCharts.DrawPlotDistributions(contentPlots); var plotsImage = ImageTools.CombineImagesVertically(images); plotsImage.Save(Path.Combine(resultsDirectory.FullName, "DistributionsOfContentScores.png")); // Attach content description plots to LDFC spectrogram and write to file var ldfcSpectrogram = Image.Load <Rgb24>(ldfcSpectrogramPath); var image = ContentVisualization.DrawLdfcSpectrogramWithContentScoreTracks(ldfcSpectrogram, contentPlots); var path3 = Path.Combine(resultsDirectory.FullName, basename + ".ContentDescription.png"); image.Save(path3); }
// OTHER CONSTANTS //private const string ImageViewer = @"C:\Windows\system32\mspaint.exe"; /// <summary> /// Do your analysis. This method is called once per segment (typically one-minute segments). /// </summary> /// <param name="recording"></param> /// <param name="configuration"></param> /// <param name="segmentStartOffset"></param> /// <param name="getSpectralIndexes"></param> /// <param name="outputDirectory"></param> /// <param name="imageWidth"></param> /// <returns></returns> public override RecognizerResults Recognize(AudioRecording recording, Config configuration, TimeSpan segmentStartOffset, Lazy <IndexCalculateResult[]> getSpectralIndexes, DirectoryInfo outputDirectory, int?imageWidth) { var recognizerConfig = new LitoriaWatjulumConfig(); recognizerConfig.ReadConfigFile(configuration); //int maxOscilRate = (int)Math.Ceiling(1 / lwConfig.MinPeriod); if (recording.WavReader.SampleRate != 22050) { throw new InvalidOperationException("Requires a 22050Hz file"); } TimeSpan recordingDuration = recording.WavReader.Time; // this default framesize seems to work const int frameSize = 128; double windowOverlap = 0.0; // calculate the overlap instead //double windowOverlap = Oscillations2012.CalculateRequiredFrameOverlap( // recording.SampleRate, // frameSize, // maxOscilRate); // i: MAKE SONOGRAM var sonoConfig = new SonogramConfig { SourceFName = recording.BaseName, //set default values - ignore those set by user WindowSize = frameSize, WindowOverlap = windowOverlap, // the default window is HAMMING //WindowFunction = WindowFunctions.HANNING.ToString(), //WindowFunction = WindowFunctions.NONE.ToString(), // if do not use noise reduction can get a more sensitive recogniser. //NoiseReductionType = NoiseReductionType.NONE, NoiseReductionType = SNR.KeyToNoiseReductionType("STANDARD"), }; //############################################################################################################################################# //DO THE ANALYSIS var results = Analysis(recording, sonoConfig, recognizerConfig, MainEntry.InDEBUG, segmentStartOffset); //###################################################################### if (results == null) { return(null); //nothing to process } var sonogram = results.Item1; var hits = results.Item2; var scoreArray = results.Item3; var predictedEvents = results.Item4; var debugImage = results.Item5; // old way of creating a path: //var debugPath = outputDirectory.Combine(FilenameHelpers.AnalysisResultName(Path.GetFileNameWithoutExtension(recording.FileName), SpeciesName, "png", "DebugSpectrogram")); var debugPath = FilenameHelpers.AnalysisResultPath(outputDirectory, recording.BaseName, this.SpeciesName, "png", "DebugSpectrogram"); debugImage.Save(debugPath); //############################################################################################################################################# // Prune events here if required i.e. remove those below threshold score if this not already done. See other recognizers. foreach (var ae in predictedEvents) { // add additional info ae.Name = recognizerConfig.AbbreviatedSpeciesName; ae.SpeciesName = recognizerConfig.SpeciesName; ae.SegmentDurationSeconds = recordingDuration.TotalSeconds; ae.SegmentStartSeconds = segmentStartOffset.TotalSeconds; } // do a recognizer TEST. if (false) { var testDir = new DirectoryInfo(outputDirectory.Parent.Parent.FullName); TestTools.RecognizerScoresTest(recording.BaseName, testDir, recognizerConfig.AnalysisName, scoreArray); AcousticEvent.TestToCompareEvents(recording.BaseName, testDir, recognizerConfig.AnalysisName, predictedEvents); } var plot = new Plot(this.DisplayName, scoreArray, recognizerConfig.EventThreshold); return(new RecognizerResults() { Sonogram = sonogram, Hits = hits, Plots = plot.AsList(), Events = predictedEvents, }); }
public static async Task <int> ExecuteAsync(Arguments arguments) { Log.Info("Event statistics analysis begin"); // validate arguments var input = arguments.Source; var config = arguments.Config.ToFileInfo(); if (!input.Exists) { throw new FileNotFoundException("Cannot find source file", input.FullName); } // try an automatically find the config file if (config == null) { throw new FileNotFoundException("No config file argument provided"); } else if (!config.Exists) { Log.Warn($"Config file {config.FullName} not found... attempting to resolve config file"); // we use the original input string - Using FileInfo fullname always produces an // absolute path relative to pwd... we don't want to prematurely make assumptions: // e.g. We require a missing absolute path to fail... that wouldn't work with .Name // e.g. We require a relative path to try and resolve, using .FullName would fail the first absolute // check inside ResolveConfigFile config = ConfigFile.Resolve(arguments.Config, Directory.GetCurrentDirectory().ToDirectoryInfo()); } // if a temp dir is not given, use output dir as temp dir if (arguments.TempDir == null) { Log.Warn("No temporary directory provided, using backup directory"); } // Remote: create an instance of our API helpers IApi api = arguments.WorkbenchApi.IsNullOrEmpty() ? Api.Default : Api.Parse(arguments.WorkbenchApi); // log some helpful messages Log.Info("Events file: " + input); Log.Info("Configuration file: " + config); Log.Info("Output folder: " + arguments.Output); Log.Info("Temp File Directory: " + arguments.TempDir); Log.Info("Api: " + api); // Remote: Test we can log in to the workbench var auth = new AuthenticationService(api); Task <IAuthenticatedApi> task; if (arguments.AuthenticationToken.IsNotWhitespace()) { Log.Debug("Using token for authentication"); task = auth.CheckLogin(arguments.AuthenticationToken); } else { var username = LoggedConsole.Prompt("Enter your username or email for the acoustic workbench:"); var password = LoggedConsole.Prompt("Enter your password for the acoustic workbench:", forPassword: true); task = auth.Login(username, password); //task = auth.Login("*****@*****.**", "tsettest"); } LoggedConsole.WriteWaitingLine(task, "Logging into workbench..."); var authenticatedApi = await task.TimeoutAfter(Service.ClientTimeout).ConfigureAwait(false); Log.Info("Login success" + authenticatedApi); // read events/annotation file Log.Info("Now reading input data"); // Read events from provided CSV file. // Also tag them with an order index to allow sorting in the same order as they were provided to us. var events = Csv .ReadFromCsv <ImportedEvent>(input, throwOnMissingField: false) .Select( (x, i) => { x.Order = i; return(x); }) .ToArray(); if (events.Length == 0) { Log.Warn("No events imported - source file empty. Exiting"); return(ExceptionLookup.NoData); } Log.Info($"Events read, {events.Length} read."); // need to validate the events var invalidEvents = events.Where(e => !e.IsValid()).ToArray(); if (invalidEvents.Length > 0) { throw new InvalidOperationException( "Invalid event detected." + $" {invalidEvents.Length} events are not valid. The first invalid event is {invalidEvents[0]}"); } // next gather meta data for all events // and transform list of events into list of segments // NOTE: to save on I/O sometimes if events share the same audio block, then multiple events will be // bundled into the same analysis segment. var resolver = new EventMetadataResolver( authenticatedApi, PaddingFunction, arguments.Parallel ? 25 : 1); var metadataTask = resolver.GetRemoteMetadata(events); // wait for 1 second per event - this should be an order of magnitude greater than what is needed ISegment <AudioRecording>[] segments = await metadataTask.TimeoutAfter(events.Length); Log.Info($"Metadata collected, preparing to start analysis"); // finally time to start preparing jobs ISourcePreparer preparer = new RemoteSourcePreparer(authenticatedApi, allowSegmentcutting: false); AnalysisCoordinator coordinator = new AnalysisCoordinator( preparer, SaveBehavior.Never, uniqueDirectoryPerSegment: false, isParallel: arguments.Parallel); // instantiate the Analysis EventStatisticsAnalysis analysis = new EventStatisticsAnalysis(); // derserialize the config file var configuration = analysis.ParseConfig(config); AnalysisSettings settings = analysis.DefaultSettings; settings.AnalysisOutputDirectory = arguments.Output; settings.AnalysisTempDirectory = arguments.TempDir; settings.Configuration = configuration; var results = coordinator.Run(segments, analysis, settings); var allEvents = results.SelectMany(es => es.Events).ToArray(); var eventsWithErrors = allEvents.Count(x => ((EventStatistics)x).Error); if (eventsWithErrors > 0) { Log.Warn($"Errors occurred when calculating statistics for {eventsWithErrors} events."); } Log.Trace("Sorting event statistics results"); Array.Sort(allEvents); Log.Info("Executing summary"); // TODO: implement if needed analysis.SummariseResults(settings, null, allEvents, null, null, results); Log.Debug("Summary complete"); var instanceOutputDirectory = AnalysisCoordinator.GetNamedDirectory(settings.AnalysisOutputDirectory, analysis); var resultName = FilenameHelpers.AnalysisResultPath( instanceOutputDirectory, input, analysis.Identifier, "csv"); // NOTE: we are only saving event files Log.Info($"Writing results to {resultName}"); analysis.WriteEventsFile(resultName.ToFileInfo(), allEvents.AsEnumerable()); Log.Debug("Writing events completed"); var summaryStats = new { numberEvents = allEvents.Length, durationEvents = allEvents.Sum(x => ((EventStatistics)x).EventDurationSeconds), numberRecordings = allEvents.Select(x => ((EventStatistics)x).AudioRecordingId).Distinct().Count(), durationAudioProcessed = results.Sum(x => x.SegmentAudioDuration.TotalSeconds), remoteAudioDownloaded = (preparer as RemoteSourcePreparer)?.TotalBytesRecieved, }; Log.Info("Summary statistics:\n" + Json.SerializeToString(summaryStats)); Log.Success("Event statistics analysis complete!"); return(ExceptionLookup.Ok); }
public static async Task <int> Execute(RibbonPlot.Arguments arguments) { if (arguments.InputDirectories.IsNullOrEmpty()) { throw new CommandLineArgumentException( $"{nameof(arguments.InputDirectories)} is null or empty - please provide at least one source directory"); } var doNotExist = arguments.InputDirectories.Where(x => !x.Exists); if (doNotExist.Any()) { throw new CommandLineArgumentException( $"The following directories given to {nameof(arguments.InputDirectories)} do not exist: " + doNotExist.FormatList()); } if (arguments.OutputDirectory == null) { arguments.OutputDirectory = arguments.InputDirectories.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.Zero) { // we need this to be width of day and not zero for rounding functions later on arguments.Midnight = RibbonPlotDomain; Log.Debug($"{nameof(arguments.Midnight)} was reset to {arguments.Midnight}"); } if (arguments.Midnight < TimeSpan.Zero || arguments.Midnight > RibbonPlotDomain) { throw new InvalidStartOrEndException($"{nameof(arguments.Midnight)} cannot be less than `00:00` or greater than `{RibbonPlotDomain}`"); } LoggedConsole.Write("Begin scanning directories"); var allIndexFiles = arguments.InputDirectories.SelectMany(IndexGenerationData.FindAll); if (allIndexFiles.IsNullOrEmpty()) { throw new MissingDataException($"Could not find `{IndexGenerationData.FileNameFragment}` files in:" + arguments.InputDirectories.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 <string, Dictionary <DateTimeOffset, FileInfo> >(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 <DateTimeOffset, FileInfo>(datedIndices.Count)); } // try to find the associated ribbon var searchPattern = "*" + colorMap + LdSpectrogramRibbons.SpectralRibbonTag + "*"; if (Log.IsVerboseEnabled()) { Log.Verbose($"Searching `{indexData.Source?.Directory}` with pattern `{searchPattern}`."); } var ribbonFile = indexData.Source?.Directory?.EnumerateFiles(searchPattern).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:O} and {stats.Max:O}, rendering between {stats.Start:O} and {stats.End:O}, in {stats.Buckets} buckets"); bool success = false; foreach (var(colorMap, ribbons) in datesMappedToColorMaps) { Log.Info($"Rendering ribbon plot for color map {colorMap}"); if (ribbons.Count(x => x.Value.NotNull()) == 0) { Log.Error($"There are no ribbon files found for color map {colorMap} - skipping this color map"); continue; } var image = CreateRibbonPlot(datedIndices, ribbons, stats); var midnight = arguments.Midnight == RibbonPlotDomain ? string.Empty : "Midnight=" + arguments.Midnight.Value.ToString("hhmm"); var path = FilenameHelpers.AnalysisResultPath( arguments.OutputDirectory, arguments.OutputDirectory.Name, "RibbonPlot", "png", colorMap, midnight); using (var file = File.Create(path)) { image.SaveAsPng(file); } image.Dispose(); success = true; } if (success == false) { throw new MissingDataException("Could not find any ribbon files for any of the color maps. No ribbon plots were produced."); } LoggedConsole.WriteSuccessLine("Completed"); return(ExceptionLookup.Ok); }
/// <summary> /// Do your analysis. This method is called once per segment (typically one-minute segments). /// </summary> public override RecognizerResults Recognize(AudioRecording recording, Config configuration, TimeSpan segmentStartOffset, Lazy <IndexCalculateResult[]> getSpectralIndexes, DirectoryInfo outputDirectory, int?imageWidth) { var recognizerConfig = new CriniaRemotaConfig(); recognizerConfig.ReadConfigFile(configuration); // BETTER TO SET THESE. IGNORE USER! // this default framesize seems to work const int frameSize = 256; const double windowOverlap = 0.25; // i: MAKE SONOGRAM var sonoConfig = new SonogramConfig { SourceFName = recording.BaseName, WindowSize = frameSize, WindowOverlap = windowOverlap, // use the default HAMMING window //WindowFunction = WindowFunctions.HANNING.ToString(), //WindowFunction = WindowFunctions.NONE.ToString(), // if do not use noise reduction can get a more sensitive recogniser. //NoiseReductionType = NoiseReductionType.None NoiseReductionType = NoiseReductionType.Standard, NoiseReductionParameter = 0.0, }; TimeSpan recordingDuration = recording.WavReader.Time; int sr = recording.SampleRate; double freqBinWidth = sr / (double)sonoConfig.WindowSize; int minBin = (int)Math.Round(recognizerConfig.MinHz / freqBinWidth) + 1; int maxBin = (int)Math.Round(recognizerConfig.MaxHz / freqBinWidth) + 1; var decibelThreshold = 6.0; BaseSonogram sonogram = new SpectrogramStandard(sonoConfig, recording.WavReader); // ###################################################################### // ii: DO THE ANALYSIS AND RECOVER SCORES OR WHATEVER int rowCount = sonogram.Data.GetLength(0); double[] amplitudeArray = MatrixTools.GetRowAveragesOfSubmatrix(sonogram.Data, 0, minBin, rowCount - 1, maxBin); double[] topBand = MatrixTools.GetRowAveragesOfSubmatrix(sonogram.Data, 0, maxBin + 3, rowCount - 1, maxBin + 9); double[] botBand = MatrixTools.GetRowAveragesOfSubmatrix(sonogram.Data, 0, minBin - 3, rowCount - 1, minBin - 9); double[] diffArray = new double[amplitudeArray.Length]; for (int i = 0; i < amplitudeArray.Length; i++) { diffArray[i] = amplitudeArray[i] - topBand[i] - botBand[i]; if (diffArray[i] < 1.0) { diffArray[i] = 0.0; } } bool[] peakArray = new bool[amplitudeArray.Length]; for (int i = 1; i < diffArray.Length - 1; i++) { if (diffArray[i] < decibelThreshold) { continue; } if (diffArray[i] > diffArray[i - 1] && diffArray[i] > diffArray[i + 1]) { peakArray[i] = true; } } // calculate score array based on density of peaks double frameDuration = (double)frameSize / sr; // use a stimulus-decay function double durationOfDecayTail = 0.35; // seconds int lengthOfDecayTail = (int)Math.Round(durationOfDecayTail / frameDuration); double decayrate = 0.95; //double decay = -0.05; //double fractionalDecay = Math.Exp(decay * lengthOfDecayTail); // the above setting gives decay of 0.22 over 0.35 seconds or 30 frames. double score = 0.0; int locationOfLastPeak = 0; double[] peakScores = new double[amplitudeArray.Length]; for (int p = 0; p < peakScores.Length - 1; p++) { if (!peakArray[p]) { int distanceFromLastpeak = p - locationOfLastPeak; // score decay score *= decayrate; // remove the decay tail if (score < 0.5 && distanceFromLastpeak > lengthOfDecayTail && p >= lengthOfDecayTail) { score = 0.0; for (int j = 0; j < lengthOfDecayTail; j++) { peakScores[p - j] = score; } } } else { locationOfLastPeak = p; score += 0.8; } peakScores[p] = score; } var events = AcousticEvent.ConvertScoreArray2Events( peakScores, recognizerConfig.MinHz, recognizerConfig.MaxHz, sonogram.FramesPerSecond, freqBinWidth, recognizerConfig.EventThreshold, recognizerConfig.MinDuration, recognizerConfig.MaxDuration, segmentStartOffset); double[,] hits = null; var prunedEvents = new List <AcousticEvent>(); foreach (var ae in events) { if (ae.EventDurationSeconds < recognizerConfig.MinDuration || ae.EventDurationSeconds > recognizerConfig.MaxDuration) { continue; } // add additional info ae.SpeciesName = recognizerConfig.SpeciesName; ae.SegmentStartSeconds = segmentStartOffset.TotalSeconds; ae.SegmentDurationSeconds = recordingDuration.TotalSeconds; ae.Name = recognizerConfig.AbbreviatedSpeciesName; prunedEvents.Add(ae); } // do a recognizer test. if (MainEntry.InDEBUG) { // var testDir = new DirectoryInfo(outputDirectory.Parent.Parent.FullName); // TestTools.RecognizerScoresTest(recording.BaseName, testDir, recognizerConfig.AnalysisName, peakScores); // AcousticEvent.TestToCompareEvents(recording.BaseName, testDir, recognizerConfig.AnalysisName, prunedEvents); } var plot = new Plot(this.DisplayName, peakScores, recognizerConfig.EventThreshold); if (false) { // display a variety of debug score arrays double[] normalisedScores; double normalisedThreshold; DataTools.Normalise(amplitudeArray, decibelThreshold, out normalisedScores, out normalisedThreshold); var amplPlot = new Plot("Band amplitude", normalisedScores, normalisedThreshold); DataTools.Normalise(diffArray, decibelThreshold, out normalisedScores, out normalisedThreshold); var diffPlot = new Plot("Diff plot", normalisedScores, normalisedThreshold); var debugPlots = new List <Plot> { plot, amplPlot, diffPlot }; // NOTE: This DrawDebugImage() method can be over-written in this class. var debugImage = DrawDebugImage(sonogram, prunedEvents, debugPlots, hits); var debugPath = FilenameHelpers.AnalysisResultPath(outputDirectory, recording.BaseName, this.SpeciesName, "png", "DebugSpectrogram"); debugImage.Save(debugPath); } return(new RecognizerResults { Sonogram = sonogram, Hits = hits, Plots = plot.AsList(), Events = prunedEvents, //Events = events }); } // Recognize()
/// <summary> /// Do your analysis. This method is called once per segment (typically one-minute segments). /// </summary> /// <param name="recording"></param> /// <param name="configuration"></param> /// <param name="segmentStartOffset"></param> /// <param name="getSpectralIndexes"></param> /// <param name="outputDirectory"></param> /// <param name="imageWidth"></param> /// <returns></returns> public override RecognizerResults Recognize(AudioRecording recording, Config configuration, TimeSpan segmentStartOffset, Lazy <IndexCalculateResult[]> getSpectralIndexes, DirectoryInfo outputDirectory, int?imageWidth) { var recognizerConfig = new LitoriaNasutaConfig(); recognizerConfig.ReadConfigFile(configuration); // BETTER TO SET THESE. IGNORE USER! // this default framesize seems to work const int frameSize = 1024; const double windowOverlap = 0.0; // i: MAKE SONOGRAM var sonoConfig = new SonogramConfig { SourceFName = recording.BaseName, WindowSize = frameSize, WindowOverlap = windowOverlap, // use the default HAMMING window //WindowFunction = WindowFunctions.HANNING.ToString(), //WindowFunction = WindowFunctions.NONE.ToString(), // if do not use noise reduction can get a more sensitive recogniser. //NoiseReductionType = NoiseReductionType.None NoiseReductionType = NoiseReductionType.Standard, NoiseReductionParameter = 0.0, }; TimeSpan recordingDuration = recording.WavReader.Time; int sr = recording.SampleRate; double freqBinWidth = sr / (double)sonoConfig.WindowSize; int minBin = (int)Math.Round(recognizerConfig.MinHz / freqBinWidth) + 1; int maxBin = (int)Math.Round(recognizerConfig.MaxHz / freqBinWidth) + 1; var decibelThreshold = 3.0; BaseSonogram sonogram = new SpectrogramStandard(sonoConfig, recording.WavReader); // ###################################################################### // ii: DO THE ANALYSIS AND RECOVER SCORES OR WHATEVER int rowCount = sonogram.Data.GetLength(0); double[] amplitudeArray = MatrixTools.GetRowAveragesOfSubmatrix(sonogram.Data, 0, minBin, rowCount - 1, maxBin); //double[] topBand = MatrixTools.GetRowAveragesOfSubmatrix(sonogram.Data, 0, maxBin + 3, (rowCount - 1), maxBin + 9); //double[] botBand = MatrixTools.GetRowAveragesOfSubmatrix(sonogram.Data, 0, minBin - 3, (rowCount - 1), minBin - 9); // ii: DO THE ANALYSIS AND RECOVER SCORES OR WHATEVER var acousticEvents = AcousticEvent.ConvertScoreArray2Events( amplitudeArray, recognizerConfig.MinHz, recognizerConfig.MaxHz, sonogram.FramesPerSecond, freqBinWidth, decibelThreshold, recognizerConfig.MinDuration, recognizerConfig.MaxDuration, segmentStartOffset); double[,] hits = null; var prunedEvents = new List <AcousticEvent>(); acousticEvents.ForEach(ae => { ae.SpeciesName = recognizerConfig.SpeciesName; ae.SegmentDurationSeconds = recordingDuration.TotalSeconds; ae.SegmentStartSeconds = segmentStartOffset.TotalSeconds; ae.Name = recognizerConfig.AbbreviatedSpeciesName; }); var thresholdedPlot = new double[amplitudeArray.Length]; for (int x = 0; x < amplitudeArray.Length; x++) { if (amplitudeArray[x] > decibelThreshold) { thresholdedPlot[x] = amplitudeArray[x]; } } var maxDb = amplitudeArray.MaxOrDefault(); double[] normalisedScores; double normalisedThreshold; DataTools.Normalise(thresholdedPlot, decibelThreshold, out normalisedScores, out normalisedThreshold); var text = string.Format($"{this.DisplayName} (Fullscale={maxDb:f1}dB)"); var plot = new Plot(text, normalisedScores, normalisedThreshold); if (true) { // display a variety of debug score arrays DataTools.Normalise(amplitudeArray, decibelThreshold, out normalisedScores, out normalisedThreshold); var amplPlot = new Plot("Band amplitude", normalisedScores, normalisedThreshold); var debugPlots = new List <Plot> { plot, amplPlot }; // NOTE: This DrawDebugImage() method can be over-written in this class. var debugImage = DrawDebugImage(sonogram, acousticEvents, debugPlots, hits); var debugPath = FilenameHelpers.AnalysisResultPath(outputDirectory, recording.BaseName, this.SpeciesName, "png", "DebugSpectrogram"); debugImage.Save(debugPath); } return(new RecognizerResults() { Sonogram = sonogram, Hits = hits, Plots = plot.AsList(), Events = acousticEvents, }); }
/// <summary> /// Do your analysis. This method is called once per segment (typically one-minute segments). /// </summary> /// <param name="recording"></param> /// <param name="configuration"></param> /// <param name="segmentStartOffset"></param> /// <param name="getSpectralIndexes"></param> /// <param name="outputDirectory"></param> /// <param name="imageWidth"></param> /// <returns></returns> public override RecognizerResults Recognize(AudioRecording recording, Config configuration, TimeSpan segmentStartOffset, Lazy <IndexCalculateResult[]> getSpectralIndexes, DirectoryInfo outputDirectory, int?imageWidth) { var recognizerConfig = new LitoriaCaeruleaConfig(); recognizerConfig.ReadConfigFile(configuration); // common properties string speciesName = configuration[AnalysisKeys.SpeciesName] ?? "<no name>"; string abbreviatedSpeciesName = configuration[AnalysisKeys.AbbreviatedSpeciesName] ?? "<no.sp>"; // BETTER TO SET THESE. IGNORE USER! // This framesize is large because the oscillation we wish to detect is due to repeated croaks // having an interval of about 0.6 seconds. The overlap is also required to give smooth oscillation. const int frameSize = 2048; const double windowOverlap = 0.5; // i: MAKE SONOGRAM var sonoConfig = new SonogramConfig { SourceFName = recording.BaseName, WindowSize = frameSize, WindowOverlap = windowOverlap, // use the default HAMMING window //WindowFunction = WindowFunctions.HANNING.ToString(), //WindowFunction = WindowFunctions.NONE.ToString(), // if do not use noise reduction can get a more sensitive recogniser. //NoiseReductionType = NoiseReductionType.None NoiseReductionType = NoiseReductionType.Standard, NoiseReductionParameter = 0.0, }; TimeSpan recordingDuration = recording.WavReader.Time; int sr = recording.SampleRate; double freqBinWidth = sr / (double)sonoConfig.WindowSize; double framesPerSecond = sr / (sonoConfig.WindowSize * (1 - windowOverlap)); //int dominantFreqBin = (int)Math.Round(recognizerConfig.DominantFreq / freqBinWidth) + 1; int minBin = (int)Math.Round(recognizerConfig.MinHz / freqBinWidth) + 1; int maxBin = (int)Math.Round(recognizerConfig.MaxHz / freqBinWidth) + 1; var decibelThreshold = 9.0; BaseSonogram sonogram = new SpectrogramStandard(sonoConfig, recording.WavReader); // ###################################################################### // ii: DO THE ANALYSIS AND RECOVER SCORES OR WHATEVER int rowCount = sonogram.Data.GetLength(0); // get the freq band as set by min and max Herz var frogBand = MatrixTools.Submatrix(sonogram.Data, 0, minBin, rowCount - 1, maxBin); // Now look for spectral maxima. For L.caerulea, the max should lie around 1100Hz +/-150 Hz. // Skip over spectra where maximum is not in correct location. int buffer = 150; var croakScoreArray = new double[rowCount]; var hzAtTopOfTopBand = recognizerConfig.DominantFreq + buffer; var hzAtBotOfTopBand = recognizerConfig.DominantFreq - buffer; var binAtTopOfTopBand = (int)Math.Round((hzAtTopOfTopBand - recognizerConfig.MinHz) / freqBinWidth); var binAtBotOfTopBand = (int)Math.Round((hzAtBotOfTopBand - recognizerConfig.MinHz) / freqBinWidth); // scan the frog band and get the decibel value of those spectra which have their maximum within the correct subband. for (int x = 0; x < rowCount; x++) { //extract spectrum var spectrum = MatrixTools.GetRow(frogBand, x); int maxIndex = DataTools.GetMaxIndex(spectrum); if (spectrum[maxIndex] < decibelThreshold) { continue; } if (maxIndex < binAtTopOfTopBand && maxIndex > binAtBotOfTopBand) { croakScoreArray[x] = spectrum[maxIndex]; } } // Perpare a normalised plot for later display with spectrogram double[] normalisedScores; double normalisedThreshold; DataTools.Normalise(croakScoreArray, decibelThreshold, out normalisedScores, out normalisedThreshold); var text1 = string.Format($"Croak scores (threshold={decibelThreshold})"); var croakPlot1 = new Plot(text1, normalisedScores, normalisedThreshold); // extract potential croak events from the array of croak candidate var croakEvents = AcousticEvent.ConvertScoreArray2Events( croakScoreArray, recognizerConfig.MinHz, recognizerConfig.MaxHz, sonogram.FramesPerSecond, freqBinWidth, recognizerConfig.EventThreshold, recognizerConfig.MinCroakDuration, recognizerConfig.MaxCroakDuration, segmentStartOffset); // add necesary info into the candidate events var prunedEvents = new List <AcousticEvent>(); foreach (var ae in croakEvents) { // add additional info ae.SpeciesName = speciesName; ae.SegmentStartSeconds = segmentStartOffset.TotalSeconds; ae.SegmentDurationSeconds = recordingDuration.TotalSeconds; ae.Name = recognizerConfig.AbbreviatedSpeciesName; prunedEvents.Add(ae); } // With those events that survive the above Array2Events process, we now extract a new array croak scores croakScoreArray = AcousticEvent.ExtractScoreArrayFromEvents(prunedEvents, rowCount, recognizerConfig.AbbreviatedSpeciesName); DataTools.Normalise(croakScoreArray, decibelThreshold, out normalisedScores, out normalisedThreshold); var text2 = string.Format($"Croak events (threshold={decibelThreshold})"); var croakPlot2 = new Plot(text2, normalisedScores, normalisedThreshold); // Look for oscillations in the difference array // duration of DCT in seconds //croakScoreArray = DataTools.filterMovingAverageOdd(croakScoreArray, 5); double dctDuration = recognizerConfig.DctDuration; // minimum acceptable value of a DCT coefficient double dctThreshold = recognizerConfig.DctThreshold; double minOscRate = 1 / recognizerConfig.MaxPeriod; double maxOscRate = 1 / recognizerConfig.MinPeriod; var dctScores = Oscillations2012.DetectOscillations(croakScoreArray, framesPerSecond, dctDuration, minOscRate, maxOscRate, dctThreshold); // ###################################################################### // ii: DO THE ANALYSIS AND RECOVER SCORES OR WHATEVER var events = AcousticEvent.ConvertScoreArray2Events( dctScores, recognizerConfig.MinHz, recognizerConfig.MaxHz, sonogram.FramesPerSecond, freqBinWidth, recognizerConfig.EventThreshold, recognizerConfig.MinDuration, recognizerConfig.MaxDuration, segmentStartOffset); double[,] hits = null; prunedEvents = new List <AcousticEvent>(); foreach (var ae in events) { // add additional info ae.SpeciesName = speciesName; ae.SegmentStartSeconds = segmentStartOffset.TotalSeconds; ae.SegmentDurationSeconds = recordingDuration.TotalSeconds; ae.Name = recognizerConfig.AbbreviatedSpeciesName; prunedEvents.Add(ae); } // do a recognizer test. if (MainEntry.InDEBUG) { //TestTools.RecognizerScoresTest(scores, new FileInfo(recording.FilePath)); //AcousticEvent.TestToCompareEvents(prunedEvents, new FileInfo(recording.FilePath)); } var scoresPlot = new Plot(this.DisplayName, dctScores, recognizerConfig.EventThreshold); if (true) { // display a variety of debug score arrays // calculate amplitude at location double[] amplitudeArray = MatrixTools.SumRows(frogBand); DataTools.Normalise(amplitudeArray, decibelThreshold, out normalisedScores, out normalisedThreshold); var amplPlot = new Plot("Band amplitude", normalisedScores, normalisedThreshold); var debugPlots = new List <Plot> { scoresPlot, croakPlot2, croakPlot1, amplPlot }; // NOTE: This DrawDebugImage() method can be over-written in this class. var debugImage = DrawDebugImage(sonogram, prunedEvents, debugPlots, hits); var debugPath = FilenameHelpers.AnalysisResultPath(outputDirectory, recording.BaseName, this.SpeciesName, "png", "DebugSpectrogram"); debugImage.Save(debugPath); } return(new RecognizerResults() { Sonogram = sonogram, Hits = hits, Plots = scoresPlot.AsList(), Events = prunedEvents, //Events = events }); }
public void SummariseResults(AnalysisSettings settings, FileSegment inputFileSegment, EventBase[] events, SummaryIndexBase[] indices, SpectralIndexBase[] spectralIndices, AnalysisResult2[] results) { var acousticIndicesConfig = (AcousticIndicesConfig)settings.AnalysisAnalyzerSpecificConfiguration; var sourceAudio = inputFileSegment.Source; var resultsDirectory = AnalysisCoordinator.GetNamedDirectory(settings.AnalysisOutputDirectory, this); bool tileOutput = acousticIndicesConfig.TileOutput; var frameWidth = acousticIndicesConfig.FrameLength; int sampleRate = AppConfigHelper.DefaultTargetSampleRate; sampleRate = acousticIndicesConfig.ResampleRate ?? sampleRate; // Gather settings for rendering false color spectrograms var ldSpectrogramConfig = acousticIndicesConfig.LdSpectrogramConfig; string basename = Path.GetFileNameWithoutExtension(sourceAudio.Name); // output to disk (so other analyzers can use the data, // only data - configuration settings that generated these indices // this data can then be used by post-process analyses /* NOTE: The value for FrameStep is used only when calculating a standard spectrogram * FrameStep is NOT used when calculating Summary and Spectral indices. */ var indexConfigData = new IndexGenerationData() { RecordingExtension = inputFileSegment.Source.Extension, RecordingBasename = basename, RecordingStartDate = inputFileSegment.TargetFileStartDate, RecordingDuration = inputFileSegment.TargetFileDuration.Value, SampleRateOriginal = inputFileSegment.TargetFileSampleRate.Value, SampleRateResampled = sampleRate, FrameLength = frameWidth, FrameStep = settings.Configuration.GetIntOrNull(AnalysisKeys.FrameStep) ?? frameWidth, IndexCalculationDuration = acousticIndicesConfig.IndexCalculationDurationTimeSpan, BgNoiseNeighbourhood = acousticIndicesConfig.BgNoiseBuffer, AnalysisStartOffset = inputFileSegment.SegmentStartOffset ?? TimeSpan.Zero, MaximumSegmentDuration = settings.AnalysisMaxSegmentDuration, BackgroundFilterCoeff = SpectrogramConstants.BACKGROUND_FILTER_COEFF, LongDurationSpectrogramConfig = ldSpectrogramConfig, }; var icdPath = FilenameHelpers.AnalysisResultPath( resultsDirectory, basename, IndexGenerationData.FileNameFragment, "json"); Json.Serialise(icdPath.ToFileInfo(), indexConfigData); // gather spectra to form spectrograms. Assume same spectra in all analyzer results // this is the most efficient way to do this // gather up numbers and strings store in memory, write to disk one time // this method also AUTOMATICALLY SORTS because it uses array indexing var dictionaryOfSpectra = spectralIndices.ToTwoDimensionalArray(SpectralIndexValues.CachedSelectors, TwoDimensionalArray.Rotate90ClockWise); // Calculate the index distribution statistics and write to a json file. Also save as png image var indexDistributions = IndexDistributions.WriteSpectralIndexDistributionStatistics(dictionaryOfSpectra, resultsDirectory, basename); // HACK: do not render false color spectrograms unless IndexCalculationDuration = 60.0 (the normal resolution) if (acousticIndicesConfig.IndexCalculationDurationTimeSpan != 60.0.Seconds()) { Log.Warn("False color spectrograms were not rendered"); } else { FileInfo indicesPropertiesConfig = acousticIndicesConfig.IndexPropertiesConfig.ToFileInfo(); // Actually draw false color / long duration spectrograms Tuple <Image <Rgb24>, string>[] images = LDSpectrogramRGB.DrawSpectrogramsFromSpectralIndices( inputDirectory: resultsDirectory, outputDirectory: resultsDirectory, ldSpectrogramConfig: ldSpectrogramConfig, indexPropertiesConfigPath: indicesPropertiesConfig, indexGenerationData: indexConfigData, basename: basename, analysisType: this.Identifier, indexSpectrograms: dictionaryOfSpectra, indexStatistics: indexDistributions, imageChrome: (!tileOutput).ToImageChrome()); if (tileOutput) { Debug.Assert(images.Length == 2); Log.Info("Tiling output at scale: " + acousticIndicesConfig.IndexCalculationDuration); foreach (var image in images) { TileOutput(resultsDirectory, Path.GetFileNameWithoutExtension(sourceAudio.Name), image.Item2 + ".Tile", inputFileSegment, image.Item1); } } } }
private DirectoryInfo CreateTestData(DirectoryInfo output) { // create 11 "day"s of data, with three spectral ribbon variants var sourceDirectory = output.CreateSubdirectory("FakeIndices"); var firstDate = new DateTimeOffset(2019, 4, 18, 0, 0, 0, TimeSpan.FromHours(10)); CreateDay(Increment(0), "ACI-ENT-EVN", "BGN-PMN-OSC", Color.Red, Color.Blue); CreateDay(Increment(1), "ACI-ENT-EVN", "BGN-PMN-OSC", Color.Red, Color.Blue); CreateDay(Increment(1), "ACI-ENT-EVN", "BGN-PMN-OSC", Color.Red, Color.Blue); CreateDay(Increment(1), "ACI-ENT-EVN", "BGN-PMN-OSC", Color.Red, Color.Blue); CreateDay(Increment(1), "ACI-ENT-EVN", "BGN-PMN-OSC", Color.Red, Color.Blue); CreateDay(Increment(1), "ACI-ENT-EVN", "BGN-PMN-OSC", Color.Red, Color.Blue); CreateDay(Increment(2), "ACI-ENT-EVN", "ENT-CVR-OSC", Color.Red, Color.Green); CreateDay(Increment(1), "ACI-ENT-EVN", "ENT-CVR-OSC", Color.Red, Color.Green); CreateDay(Increment(1), "ACI-ENT-EVN", "ENT-CVR-OSC", Color.Red, Color.Green); CreateDay(Increment(1), "ACI-ENT-EVN", "ENT-CVR-OSC", Color.Red, Color.Green); CreateDay(Increment(1), "ACI-ENT-EVN", "ENT-CVR-OSC", Color.Red, Color.Green); return(sourceDirectory); DateTimeOffset Increment(int days) { firstDate = firstDate.AddDays(days); return(firstDate); } void CreateDay(DateTimeOffset startDate, string colorMap1, string colorMap2, Rgb24 color1, Rgb24 color2) { var basename = startDate.ToIso8601SafeString(); var extension = Random.NextChoice(".wav", ".mp3", ".flac"); var data = new IndexGenerationData() { //Source = this.outputDirectory.CombineFile(name), AnalysisStartOffset = TimeSpan.Zero, BackgroundFilterCoeff = SpectrogramConstants.BACKGROUND_FILTER_COEFF, BgNoiseNeighbourhood = IndexCalculateConfig.DefaultBgNoiseNeighborhood.Seconds(), FrameLength = 512, FrameStep = 512, IndexCalculationDuration = 60.Seconds(), LongDurationSpectrogramConfig = new LdSpectrogramConfig() { ColorMap1 = colorMap1, ColorMap2 = colorMap2, }, MaximumSegmentDuration = 60.Seconds(), RecordingBasename = basename, RecordingDuration = TimeSpan.FromHours(24), RecordingExtension = extension, RecordingStartDate = startDate, SampleRateOriginal = 22050, SampleRateResampled = 22050, }; var icdPath = FilenameHelpers.AnalysisResultPath( sourceDirectory, basename, IndexGenerationData.FileNameFragment, "json"); Json.Serialise(icdPath.ToFileInfo(), data); var ribbon = new Image <Rgb24>(Configuration.Default, 1440, LdSpectrogramRibbons.RibbonPlotHeight, color1); ribbon.Save(FilenameHelpers.AnalysisResultPath(sourceDirectory, basename, colorMap1 + LdSpectrogramRibbons.SpectralRibbonTag, "png")); ribbon = new Image <Rgb24>(Configuration.Default, 1440, LdSpectrogramRibbons.RibbonPlotHeight, color2); ribbon.Save(FilenameHelpers.AnalysisResultPath(sourceDirectory, basename, colorMap2 + LdSpectrogramRibbons.SpectralRibbonTag, "png")); } }