From 5e181f51312e53a72d354584439fa9359cd16723 Mon Sep 17 00:00:00 2001 From: Lucille Delisle Date: Thu, 15 Feb 2024 13:26:43 +0100 Subject: [PATCH] release 20240214 for phase measurement --- ...melapse_image_to_measurements_phase.groovy | 229 +++++++++++++----- .../README.md | 37 +++ ..._hyperstack_to_gastruloid_measurements.xml | 74 ++++-- 3 files changed, 263 insertions(+), 77 deletions(-) diff --git a/tools/omero_hyperstack_to_gastruloid_measurements/1-omero_timelapse_image_to_measurements_phase.groovy b/tools/omero_hyperstack_to_gastruloid_measurements/1-omero_timelapse_image_to_measurements_phase.groovy index 12a48c4..fc04b6f 100644 --- a/tools/omero_hyperstack_to_gastruloid_measurements/1-omero_timelapse_image_to_measurements_phase.groovy +++ b/tools/omero_hyperstack_to_gastruloid_measurements/1-omero_timelapse_image_to_measurements_phase.groovy @@ -5,11 +5,11 @@ // merge the analysis script with templates available at // https://github.com/BIOP/OMERO-scripts/tree/025047955b5c1265e1a93b259c1de4600d00f107/Fiji -// Last modification: 2023-12-20 +// Last modification: 2024-02-14 /* * = COPYRIGHT = - * © All rights reserved. ECOLE POLYTECHNIQUE FEDERALE DE LAUSANNE, Switzerland, BioImaging And Optics Platform (BIOP), 2023 + * © All rights reserved. ECOLE POLYTECHNIQUE FEDERALE DE LAUSANNE, Switzerland, BioImaging And Optics Platform (BIOP), 2024 * * Licensed under the BSD-3-Clause License: * Redistribution and use in source and binary forms, with or without modification, are permitted provided @@ -45,19 +45,22 @@ // The option 'rescue' allows to only process images without ROIs and // tables and generate the final table -// Without this option, the job will fail if a ROI or a table exists +// The option 'replace_at_runtime' allows to remove tables at start +// and ROIs on each image just before processing. -// If a final table exists it will fail in both modes - -// The option use_existing allows to +// The option 'use_existing' allows to // Recompute only spine +// Without 'use_existing' or 'replace_at_runtime', the job will fail if a final table exists + +// Without 'use_existing' or 'replace_at_runtime' or 'rescue', the job will fail if ROI exists + // This macro works both in headless // or GUI // In both modes, -// The result table and the result ROI are sent to omero -// The measures are: Area,Perim.,Circ.,Feret,FeretX,FeretY,FeretAngle,MinFeret,AR,Round,Solidity,Unit,Date,Version,IlastikProject,ProbabilityThreshold,MinSizeParticle,MinDiameter,ClosenessTolerance,MinSimilarity,RadiusMedian,BaseImage,ROI,Time,ROI_type,XCentroid,YCentroid,LargestRadius,SpineLength,ElongationIndex[,Date_rerun_spine,Version_rerun_spine] +// The result table and the result ROIs are sent to omero +// The measures are: Area,Perim.,Circ.,Feret,FeretX,FeretY,FeretAngle,MinFeret,AR,Round,Solidity,Unit,Date,Version,IlastikProject,ProbabilityThreshold,ThresholdingMethod,OptionsDo,OptionsIterations,OptionsCount,FillHolesBefore,RadiusMedian,MinSizeParticle,MinDiameter,ClosenessTolerance,MinSimilarity,BaseImage,ROI,Time,ROI_type,XCentroid,YCentroid,LargestRadius,SpineLength,ElongationIndex[,Date_rerun_spine,Version_rerun_spine] // LargestRadius and SpineLength are set to 0 if no circle was found. // ElongationIndex is set to 0 if a gastruloid was found and to -1 if no gastruloid was found. @@ -397,26 +400,39 @@ def processDataset(Client user_client, DatasetWrapper dataset_wrp, File ilastik_project, String ilastik_project_type, Integer ilastik_label_OI, Double probability_threshold, Double radius_median, - Integer min_size_particle, Boolean get_spine, - Integer minimum_diameter, Integer closeness_tolerance, Double min_similarity, + Double min_size_particle, Boolean get_spine, + Double minimum_diameter_um, Double closeness_tolerance_um, Double min_similarity, String ilastik_project_short_name, File output_directory, Boolean headless_mode, Boolean debug, String tool_version, Boolean use_existing, String final_object, Boolean rescue, Integer ilastik_label_BG, Double probability_threshold_BG, - Boolean keep_only_largest, String segmentation_method) { + Boolean keep_only_largest, String segmentation_method, + Boolean replace_at_runtime, + String thresholding_method, String options_do, + Integer options_iteration, Integer options_count, + Boolean fill_holes_before_median) { robustlyGetAll(dataset_wrp, "image", user_client).each{ ImageWrapper img_wrp -> + if (replace_at_runtime) { + List rois = robustlyGetROIs(image_wrp, user_client) + if (!rois.isEmpty()) { + robustlyDeleteROIs(image_wrp, user_client, rois) + } + } processImage(user_client, img_wrp, ilastik_project, ilastik_project_type, ilastik_label_OI, probability_threshold, radius_median, min_size_particle, get_spine, - minimum_diameter, closeness_tolerance, min_similarity, + minimum_diameter_um, closeness_tolerance_um, min_similarity, ilastik_project_short_name, output_directory, headless_mode, debug, tool_version, use_existing, final_object, rescue, ilastik_label_BG, probability_threshold_BG, - keep_only_largest, segmentation_method) + keep_only_largest, segmentation_method, + thresholding_method, options_do, + options_iteration, options_count, + fill_holes_before_median) } } @@ -424,26 +440,34 @@ def processSinglePlate(Client user_client, PlateWrapper plate_wrp, File ilastik_project, String ilastik_project_type, Integer ilastik_label_OI, Double probability_threshold, Double radius_median, - Integer min_size_particle, Boolean get_spine, - Integer minimum_diameter, Integer closeness_tolerance, Double min_similarity, + Double min_size_particle, Boolean get_spine, + Double minimum_diameter_um, Double closeness_tolerance_um, Double min_similarity, String ilastik_project_short_name, File output_directory, Boolean headless_mode, Boolean debug, String tool_version, Boolean use_existing, String final_object, Boolean rescue, Integer ilastik_label_BG, Double probability_threshold_BG, - Boolean keep_only_largest, String segmentation_method) { + Boolean keep_only_largest, String segmentation_method, + Boolean replace_at_runtime, + String thresholding_method, String options_do, + Integer options_iteration, Integer options_count, + Boolean fill_holes_before_median) { robustlyGetAll(plate_wrp, "well", user_client).each{ well_wrp -> processSingleWell(user_client, well_wrp, ilastik_project, ilastik_project_type, ilastik_label_OI, probability_threshold, radius_median, min_size_particle, get_spine, - minimum_diameter, closeness_tolerance, min_similarity, + minimum_diameter_um, closeness_tolerance_um, min_similarity, ilastik_project_short_name, output_directory, headless_mode, debug, tool_version, use_existing, final_object, rescue, ilastik_label_BG, probability_threshold_BG, - keep_only_largest, segmentation_method) + keep_only_largest, segmentation_method, + replace_at_runtime, + thresholding_method, options_do, + options_iteration, options_count, + fill_holes_before_median) } } @@ -451,41 +475,58 @@ def processSingleWell(Client user_client, WellWrapper well_wrp, File ilastik_project, String ilastik_project_type, Integer ilastik_label_OI, Double probability_threshold, Double radius_median, - Integer min_size_particle, Boolean get_spine, - Integer minimum_diameter, Integer closeness_tolerance, Double min_similarity, + Double min_size_particle, Boolean get_spine, + Double minimum_diameter_um, Double closeness_tolerance_um, Double min_similarity, String ilastik_project_short_name, File output_directory, Boolean headless_mode, Boolean debug, String tool_version, Boolean use_existing, String final_object, Boolean rescue, Integer ilastik_label_BG, Double probability_threshold_BG, - Boolean keep_only_largest, String segmentation_method) { + Boolean keep_only_largest, String segmentation_method, + Boolean replace_at_runtime, + String thresholding_method, String options_do, + Integer options_iteration, Integer options_count, + Boolean fill_holes_before_median) { well_wrp.getWellSamples().each{ - processImage(user_client, it.getImage(), + ImageWrapper img_wrp = it.getImage() + if (replace_at_runtime) { + List rois = robustlyGetROIs(img_wrp, user_client) + if (!rois.isEmpty()) { + robustlyDeleteROIs(img_wrp, user_client, rois) + } + } + processImage(user_client, img_wrp, ilastik_project, ilastik_project_type, ilastik_label_OI, probability_threshold, radius_median, min_size_particle, get_spine, - minimum_diameter, closeness_tolerance, min_similarity, + minimum_diameter_um, closeness_tolerance_um, min_similarity, ilastik_project_short_name, output_directory, headless_mode, debug, tool_version, use_existing, final_object, rescue, ilastik_label_BG, probability_threshold_BG, - keep_only_largest, segmentation_method) + keep_only_largest, segmentation_method, + thresholding_method, options_do, + options_iteration, options_count, + fill_holes_before_median) } } def processImage(Client user_client, ImageWrapper image_wrp, File ilastik_project, String ilastik_project_type, // String ilastik_strategy, Integer ilastik_label_OI, - Double probability_threshold, Double radius_median, Integer min_size_particle, + Double probability_threshold, Double radius_median, Double min_size_particle, Boolean get_spine, - Integer minimum_diameter, Integer closeness_tolerance, Double min_similarity, + Double minimum_diameter_um, Double closeness_tolerance_um, Double min_similarity, String ilastik_project_short_name, File output_directory, Boolean headless_mode, Boolean debug, String tool_version, Boolean use_existing, String final_object, Boolean rescue, Integer ilastik_label_BG, Double probability_threshold_BG, - Boolean keep_only_largest, String segmentation_method) { + Boolean keep_only_largest, String segmentation_method, + String thresholding_method, String options_do, + Integer options_iteration, Integer options_count, + Boolean fill_holes_before_median) { IJ.run("Close All", "") IJ.run("Clear Results") @@ -584,7 +625,7 @@ def processImage(Client user_client, ImageWrapper image_wrp, use_existing = false rescue = false rois = robustlyGetROIs(image_wrp, user_client) - if (rois.size() > 0) { + if (!rois.isEmpty()) { // Clean existing ROIs robustlyDeleteROIs(image_wrp, user_client, rois) } @@ -693,17 +734,19 @@ def processImage(Client user_client, ImageWrapper image_wrp, // Get only the channel with bright field mask_imp = new Duplicator().run(imp, ilastik_input_ch, ilastik_input_ch, 1, 1, 1, nT); // Run convert to mask - (new Thresholder()).convertStackToBinary(mask_imp); + Thresholder my_thresholder = new Thresholder() + my_thresholder.setMethod(thresholding_method) + my_thresholder.setBackground("Light") + Prefs.blackBackground = true + my_thresholder.convertStackToBinary(mask_imp) } // This title will appear in the result table mask_imp.setTitle(image_basename) if (!headless_mode) { mask_imp.show() } - - // clean the mask a bit - // Before we were doing: - // IJ.run(mask_ilastik_imp, "Options...", "iterations=10 count=3 black do=Open") - // Now: - // (Romain proposed 5 as radius_median) + IJ.run(mask_imp, "Options...", "iterations=" + options_iteration + " count=" + options_count + " black do=" + options_do + " stack") + if (fill_holes_before_median) { + IJ.run(mask_imp, "Fill Holes", "stack") + } println "Smoothing mask" // Here I need to check if we first fill holes or first do the median @@ -714,7 +757,8 @@ def processImage(Client user_client, ImageWrapper image_wrp, // find gastruloids and measure them IJ.run("Set Measurements...", "area feret's perimeter shape display redirect=None decimal=3") - IJ.run("Set Scale...", "distance=1 known=" + scale + " unit=micron") + + IJ.run(mask_imp, "Set Scale...", "distance=1 known=" + scale + " unit=micron") pixelWidth = mask_imp.getCalibration().pixelWidth println "pixelWidth is " + pixelWidth // Exclude the edge @@ -732,6 +776,13 @@ def processImage(Client user_client, ImageWrapper image_wrp, for (int t=1;t<=nT;t++) { // Don't ask me why we need to refer to Z pos and not T/Frame ArrayList all_rois_inT = ov.findAll{ roi -> roi.getZPosition() == t} + // When there is a single time the ROI has ZPosition to 0: + if (nT == 1) { + all_rois_inT = (ov as List) + if (all_rois_inT == null) { + all_rois_inT = [] + } + } println "There are " + all_rois_inT.size() + " in time " + t if (all_rois_inT.size() > 0) { largest_roi_inT = Collections.max(all_rois_inT, Comparator.comparing((roi) -> roi.getStatistics().area )) @@ -744,6 +795,9 @@ def processImage(Client user_client, ImageWrapper image_wrp, // Update the position before adding to the clean_overlay largest_roi_inT.setPosition( ilastik_input_ch, 1, t) clean_overlay.add(largest_roi_inT) + if (!headless_mode) { + rm.addRoi(largest_roi_inT) + } } } else { // We keep all @@ -752,6 +806,10 @@ def processImage(Client user_client, ImageWrapper image_wrp, ov.each{ Roi roi -> // Don't ask me why we need to refer to Z pos and not T/Frame t = roi.getZPosition() + // When there is a single time the ROI has ZPosition to 0: + if (nT == 1) { + t = 1 + } id = lastID[t - 1] + 1 roi.setName("Gastruloid_t" + t + "_id" + id) // Increase lastID: @@ -759,6 +817,9 @@ def processImage(Client user_client, ImageWrapper image_wrp, // Update the position before adding to the clean_overlay roi.setPosition( ilastik_input_ch, 1, t) clean_overlay.add(roi) + if (!headless_mode) { + rm.addRoi(roi) + } } // Fill timepoints with no ROI with notfound: Roi roi @@ -789,15 +850,21 @@ def processImage(Client user_client, ImageWrapper image_wrp, if (segmentation_method == "ilastik") { rt.setValue("IlastikProject", row, ilastik_project_short_name) rt.setValue("ProbabilityThreshold", row, probability_threshold) + rt.setValue("ThresholdingMethod", row, "NA") } else { rt.setValue("IlastikProject", row, "NA") rt.setValue("ProbabilityThreshold", row, "NA") + rt.setValue("ThresholdingMethod", row, thresholding_method) } + rt.setValue("OptionsDo", row, options_do) + rt.setValue("OptionsIterations", row, options_iteration) + rt.setValue("OptionsCount", row, options_count) + rt.setValue("FillHolesBefore", row, "" + fill_holes_before_median) + rt.setValue("RadiusMedian", row, radius_median) rt.setValue("MinSizeParticle", row, min_size_particle) - rt.setValue("MinDiameter", row, minimum_diameter) - rt.setValue("ClosenessTolerance", row, closeness_tolerance) + rt.setValue("MinDiameter", row, minimum_diameter_um) + rt.setValue("ClosenessTolerance", row, closeness_tolerance_um) rt.setValue("MinSimilarity", row, min_similarity) - rt.setValue("RadiusMedian", row, radius_median) String label = rt.getLabel(row) rt.setValue("BaseImage", row, label.split(":")[0]) rt.setValue("ROI", row, label.split(":")[1]) @@ -852,8 +919,8 @@ def processImage(Client user_client, ImageWrapper image_wrp, for ( int row = 0;row tables = robustlyGetTables(image_wrp, user_client) if (!tables.isEmpty()) { - throw new Exception("There should be no table associated to the image before segmentation. Please clean the image.") + if (replace_at_runtime) { + robustlyDeleteTables(image_wrp, user_client) + } else { + throw new Exception("There should be no table associated to the image before segmentation. Please clean the image.") + } } if (!rescue) { List rois = robustlyGetROIs(image_wrp, user_client) if (!rois.isEmpty()) { - throw new Exception("There should be no ROIs associated to the image before segmentation. Please clean the image.") + if (replace_at_runtime) { + robustlyDeleteROIs(image_wrp, user_client, rois) + } else { + throw new Exception("There should be no ROIs associated to the image before segmentation. Please clean the image.") + } } } } @@ -1217,17 +1305,20 @@ if (user_client.isConnected()) { ilastik_project, ilastik_project_type, ilastik_label_OI, probability_threshold, radius_median, min_size_particle, - get_spine, minimum_diameter, closeness_tolerance, min_similarity, + get_spine, minimum_diameter_um, closeness_tolerance_um, min_similarity, ilastik_project_short_name, output_directory, headless_mode, debug, tool_version, use_existing, "image", rescue, ilastik_label_BG, probability_threshold_BG, - keep_only_largest, segmentation_method) + keep_only_largest, segmentation_method, + thresholding_method, options_do, + options_iteration, options_count, + fill_holes_before_median) break case "dataset": DatasetWrapper dataset_wrp = robustlyGetOne(id, "dataset", user_client) - if (use_existing) { + if (use_existing || replace_at_runtime) { // Remove the tables associated to the dataset robustlyDeleteTables(dataset_wrp, user_client) } else if (rescue) { @@ -1244,20 +1335,24 @@ if (user_client.isConnected()) { ilastik_project, ilastik_project_type, ilastik_label_OI, probability_threshold, radius_median, min_size_particle, - get_spine, minimum_diameter, closeness_tolerance, min_similarity, + get_spine, minimum_diameter_um, closeness_tolerance_um, min_similarity, ilastik_project_short_name, output_directory, headless_mode, debug, tool_version, use_existing, "dataset", rescue, ilastik_label_BG, probability_threshold_BG, - keep_only_largest, segmentation_method) + keep_only_largest, segmentation_method, + replace_at_runtime, + thresholding_method, options_do, + options_iteration, options_count, + fill_holes_before_median) // upload the table on OMERO super_table.setName(table_name + "_global") robustlyAddAndReplaceTable(dataset_wrp, user_client, super_table) break case "well": WellWrapper well_wrp = robustlyGetOne(id, "well", user_client) - if (use_existing) { + if (use_existing || replace_at_runtime) { // Remove the tables associated to the well robustlyDeleteTables(well_wrp, user_client) } else if (rescue) { @@ -1274,20 +1369,24 @@ if (user_client.isConnected()) { ilastik_project, ilastik_project_type, ilastik_label_OI, probability_threshold, radius_median, min_size_particle, - get_spine, minimum_diameter, closeness_tolerance, min_similarity, + get_spine, minimum_diameter_um, closeness_tolerance_um, min_similarity, ilastik_project_short_name, output_directory, headless_mode, debug, tool_version, use_existing, "well", rescue, ilastik_label_BG, probability_threshold_BG, - keep_only_largest, segmentation_method) + keep_only_largest, segmentation_method, + replace_at_runtime, + thresholding_method, options_do, + options_iteration, options_count, + fill_holes_before_median) // upload the table on OMERO super_table.setName(table_name + "_global") robustlyAddAndReplaceTable(well_wrp, user_client, super_table) break case "plate": PlateWrapper plate_wrp = robustlyGetOne(id, "plate", user_client) - if (use_existing) { + if (use_existing || replace_at_runtime) { // Remove the tables associated to the plate robustlyDeleteTables(plate_wrp, user_client) } else if (rescue) { @@ -1304,13 +1403,17 @@ if (user_client.isConnected()) { ilastik_project, ilastik_project_type, ilastik_label_OI, probability_threshold, radius_median, min_size_particle, - get_spine, minimum_diameter, closeness_tolerance, min_similarity, + get_spine, minimum_diameter_um, closeness_tolerance_um, min_similarity, ilastik_project_short_name, output_directory, headless_mode, debug, tool_version, use_existing, "plate", rescue, ilastik_label_BG, probability_threshold_BG, - keep_only_largest, segmentation_method) + keep_only_largest, segmentation_method, + replace_at_runtime, + thresholding_method, options_do, + options_iteration, options_count, + fill_holes_before_median) // upload the table on OMERO super_table.setName(table_name + "_global") robustlyAddAndReplaceTable(plate_wrp, user_client, super_table) diff --git a/tools/omero_hyperstack_to_gastruloid_measurements/README.md b/tools/omero_hyperstack_to_gastruloid_measurements/README.md index a51999e..1b49b75 100644 --- a/tools/omero_hyperstack_to_gastruloid_measurements/README.md +++ b/tools/omero_hyperstack_to_gastruloid_measurements/README.md @@ -1,7 +1,44 @@ # OMERO hyperstack to gastruloid measurements +## Set up user credentials on Galaxy to connect to other omero instance + +To enable users to set their credentials for this tool, +make sure the file `config/user_preferences_extra.yml` has the following section: + +``` + omero_account: + description: Your OMERO instance connection credentials + inputs: + - name: username + label: Username + type: text + required: False + - name: password + label: Password + type: password + required: False +``` + +## Dependencies + +This tool requires the channels: `--channel conda-forge --channel bioconda --channel defaults --channel pytorch --channel ilastik-forge`. + + ## CHANGELOG +### 20240214 + +- Update fiji, max_inscribed_circles, omero_ij, simple_omero_client +- Allow to do 'Replace at runtime' where it deletes the ROIs of the image before running the analysis on the image and remove all tables linked to the object on which the script is run. +- Add a parameter for the thresholding method when using 'convert_to_mask'. +- Allow to run 'Options...' and 'Fill Holes' between the mask and the median step. +- As a consequence the tables have more columns than before. +- Use um as unit for minimum_diameter and closeness_tolerance. +- Use Double for minimum_diameter_um, closeness_tolerance_um and min_size_particle +- Change the default of min_size_particle from 5000 to 20000. +- Change the default of minimume_diameter from 20 to 40. +- Change the default of radius_median from 20 to 10. + ### 20231220 - Add a new parameter: segmentation_method which can be 'ilastik' or 'convert_to_mask'. If 'convert_to_mask' is chosen, it does an autothreshold. diff --git a/tools/omero_hyperstack_to_gastruloid_measurements/omero_hyperstack_to_gastruloid_measurements.xml b/tools/omero_hyperstack_to_gastruloid_measurements/omero_hyperstack_to_gastruloid_measurements.xml index 7c81b85..4fa5e3f 100644 --- a/tools/omero_hyperstack_to_gastruloid_measurements/omero_hyperstack_to_gastruloid_measurements.xml +++ b/tools/omero_hyperstack_to_gastruloid_measurements/omero_hyperstack_to_gastruloid_measurements.xml @@ -1,6 +1,6 @@ - 20231220 + 20240214 @@ -29,8 +29,28 @@ + + + + + + + + + + + + + + + + + + + + @@ -40,18 +60,34 @@ - - +
+ + + + + + + + + + + + + +
+ + +
- fiji + fiji python - fiji-max_inscribed_circles + fiji-max_inscribed_circles fiji-ilastik - fiji-omero_ij - fiji-simple_omero_client + fiji-omero_ij + fiji-simple_omero_client output.log + 'USERNAME="",PASSWORD="",credentials="${credentials}",host="${omero_host}",port="${omero_port}",object_type="${omero_object.object_type}",id="${omero_object.omero_id}",segmentation_method="${mode.use_ilastik.segmentation_method}",use_existing="${mode.use_existing}",ilastik_project="$ilastik_project_file",ilastik_project_short_name="$ilastik_project_name",ilastik_project_type="${mode.use_ilastik.ilastik_project_type}",ilastik_label_OI="${mode.use_ilastik.ilastik_label_OI}",probability_threshold="${mode.use_ilastik.probability_threshold}",radius_median="${mode.radius_median}",min_size_particle="${mode.min_size_particle}",get_spine="true",minimum_diameter_um="${minimum_diameter_um}",closeness_tolerance_um="${closeness_tolerance_um}",min_similarity="${min_similarity}",output_directory="output",debug="${debug}",rescue="${mode.rescue}",ilastik_label_BG="${mode.use_ilastik.background.ilastik_label_BG}",probability_threshold_BG="${mode.use_ilastik.background.probability_threshold_BG}",keep_only_largest="${mode.keep_only_largest}",replace_at_runtime="${mode.replace_at_runtime}",thresholding_method="${mode.use_ilastik.thresholding_method}",options_do="${mode.options.options_do}",options_iteration="${mode.options.options_iteration}",options_count="${mode.options.options_count}",fill_holes_before_median="${mode.fill_holes_before_median}"' > output.log ]]> @@ -113,17 +149,26 @@ $password - + + + + + + + + + + @@ -138,15 +183,16 @@ $password - - + + + - - + + @@ -188,7 +234,7 @@ License text:: /* * = COPYRIGHT = - * © All rights reserved. ECOLE POLYTECHNIQUE FEDERALE DE LAUSANNE, Switzerland, BioImaging And Optics Platform (BIOP), 2023 + * © All rights reserved. ECOLE POLYTECHNIQUE FEDERALE DE LAUSANNE, Switzerland, BioImaging And Optics Platform (BIOP), 2024 * * Licensed under the BSD-3-Clause License: * Redistribution and use in source and binary forms, with or without modification, are permitted provided