Gigas002/GTiff2Tiles

View on GitHub
GTiff2Tiles.Core/GeoTiffs/Raster.cs

Summary

Maintainability
C
1 day
Test Coverage
using System.Diagnostics;
using System.Threading.Channels;
using GTiff2Tiles.Core.Constants;
using GTiff2Tiles.Core.Coordinates;
using GTiff2Tiles.Core.Enums;
using GTiff2Tiles.Core.Exceptions;
using GTiff2Tiles.Core.Helpers;
using GTiff2Tiles.Core.Images;
using GTiff2Tiles.Core.Localization;
using GTiff2Tiles.Core.Tiles;
using NetVips;

// ReSharper disable ClassWithVirtualMembersNeverInherited.Global
// ReSharper disable MemberCanBePrivate.Global
// ReSharper disable UnusedMember.Global

namespace GTiff2Tiles.Core.GeoTiffs;

/// <summary>
/// Class, representing <see cref="Raster"/> GeoTiff.
/// Used for creating <see cref="RasterTile"/>s
/// </summary>
public class Raster : GeoTiff
{
    #region Properties

    /// <summary>
    /// This <see cref="Raster"/>'s data
    /// </summary>
    public Image Data { get; }

    #endregion

    #region Constructor/Destructor

    /// <summary>
    /// Creates new <see cref="Raster"/> object
    /// <remarks><para/>Use this version ONLY if you don't know the <see cref="CoordinateSystem"/>
    /// of this <see cref="Raster"/>. In other cases, prefer using other constructors!</remarks>
    /// </summary>
    /// <inheritdoc cref="Raster(string,CoordinateSystem,long)"/>
    /// <param name="inputFilePath"></param>
    /// <param name="maxMemoryCache"></param>
    public Raster(string inputFilePath, long maxMemoryCache = 2147483648)
    {
        #region Preconditions checks

        CheckHelper.CheckFile(inputFilePath, true, FileExtensions.Tif);

        if (maxMemoryCache <= 0) throw new ArgumentOutOfRangeException(nameof(maxMemoryCache));

        #endregion

        // Disable NetVips warnings for tiff
        NetVipsHelper.DisableLog();

        // Get coordinate system of input geotiff from gdal
        string proj4String = GdalWorker.GetProjString(inputFilePath);
        CoordinateSystem coordinateSystem = GdalWorker.GetCoordinateSystem(proj4String);

        if (coordinateSystem == CoordinateSystem.Other)
        {
            string err = string.Format(Strings.Culture, Strings.NotSupported, coordinateSystem);

            throw new NotSupportedException(err);
        }

        bool memory = new FileInfo(inputFilePath).Length <= maxMemoryCache;
        Data = Image.NewFromFile(inputFilePath, memory, NetVips.Enums.Access.Random);

        // Get border coordinates и raster sizes
        Size = new Size(Data.Width, Data.Height);

        GeoCoordinateSystem = coordinateSystem;
        (MinCoordinate, MaxCoordinate) = GdalWorker.GetImageBorders(inputFilePath, Size, GeoCoordinateSystem);
    }

    /// <summary>
    /// Creates new <see cref="Raster"/> object
    /// </summary>
    /// <param name="inputFilePath">Input GeoTiff's path
    /// <remarks><para/>Must have .tif extension</remarks></param>
    /// <param name="coordinateSystem">GeoTiff's coordinate system
    /// <remarks><para/>If set to <see cref="CoordinateSystem.Other"/>
    /// throws <see cref="ArgumentOutOfRangeException"/></remarks></param>
    /// <param name="maxMemoryCache">Max size of input image to store in RAM
    /// <remarks><para/>Must be > 0. 2GB by default</remarks></param>
    /// <exception cref="ArgumentOutOfRangeException"/>
    /// <exception cref="NotSupportedException"/>
    public Raster(string inputFilePath, CoordinateSystem coordinateSystem, long maxMemoryCache = 2147483648)
    {
        #region Preconditions checks

        CheckHelper.CheckFile(inputFilePath, true, FileExtensions.Tif);

        if (coordinateSystem == CoordinateSystem.Other)
        {
            string err = string.Format(Strings.Culture, Strings.NotSupported, coordinateSystem);

            throw new NotSupportedException(err);
        }

        if (maxMemoryCache <= 0) throw new ArgumentOutOfRangeException(nameof(maxMemoryCache));

        #endregion

        // Disable NetVips warnings for tiff
        NetVipsHelper.DisableLog();

        bool memory = new FileInfo(inputFilePath).Length <= maxMemoryCache;
        Data = Image.NewFromFile(inputFilePath, memory, NetVips.Enums.Access.Random);

        // Get border coordinates и raster sizes
        Size = new Size(Data.Width, Data.Height);

        GeoCoordinateSystem = coordinateSystem;
        (MinCoordinate, MaxCoordinate) = GdalWorker.GetImageBorders(inputFilePath, Size, GeoCoordinateSystem);
    }

    /// <inheritdoc cref="Raster(string,CoordinateSystem,long)"/>
    /// <param name="inputStream"><see cref="Stream"/> with GeoTiff</param>
    /// <param name="coordinateSystem"></param>
    public Raster(Stream inputStream, CoordinateSystem coordinateSystem)
    {
        #region Preconditions checks

        if (inputStream == null) throw new ArgumentNullException(nameof(inputStream));

        if (coordinateSystem == CoordinateSystem.Other)
        {
            string err = string.Format(Strings.Culture, Strings.NotSupported, coordinateSystem);

            throw new NotSupportedException(err);
        }

        #endregion

        // Disable NetVips warnings for tiff
        NetVipsHelper.DisableLog();

        (MinCoordinate, MaxCoordinate) = GetBorders(inputStream, coordinateSystem);
        Data = Image.NewFromStream(inputStream, access: NetVips.Enums.Access.Random);

        // Reset stream reading position
        inputStream.Seek(0, SeekOrigin.Begin);

        // Get border coordinates и raster sizes
        Size = new Size(Data.Width, Data.Height);

        GeoCoordinateSystem = coordinateSystem;
    }

    /// <summary>
    /// Calls <see cref="Dispose(bool)"/> on this <see cref="Raster"/>
    /// </summary>
    ~Raster() => Dispose(false);

    #endregion

    #region Methods

    #region Dispose

    /// <inheritdoc />
    protected override void Dispose(bool disposing)
    {
        if (IsDisposed) return;

        base.Dispose(disposing);

        if (disposing)
        {
            // Occurs only if called by programmer. Dispose static things here
        }

        Data?.Dispose();
    }

    #endregion

    #region Create tile image

    /// <summary>
    /// Create <see cref="Image"/> for one <see cref="RasterTile"/>
    /// from input <see cref="Image"/> or tile cache
    /// </summary>
    /// <param name="tileCache">Source <see cref="Image"/>
    /// or tile cache</param>
    /// <param name="tile">Target <see cref="RasterTile"/></param>
    /// <returns>Ready <see cref="Image"/> for <see cref="RasterTile"/></returns>
    /// <exception cref="ArgumentNullException"/>
    /// <exception cref="ArgumentException"/>
    public Image CreateTileImage(Image tileCache, RasterTile tile, Area readArea, Area writeArea)
    {
        #region Preconditions checks

        if (tileCache == null) throw new ArgumentNullException(nameof(tileCache));
        if (tile == null) throw new ArgumentNullException(nameof(tile));

        #endregion


        // Scaling calculations
        double xScale = (double)writeArea.Size.Width / readArea.Size.Width;
        double yScale = (double)writeArea.Size.Height / readArea.Size.Height;

        // Crop and resize tile
        Image tempTileImage = tileCache.Crop((int)readArea.OriginCoordinate.X, (int)readArea.OriginCoordinate.Y,
                                             readArea.Size.Width, readArea.Size.Height)
                                       .Resize(xScale, tile.Interpolation, null, yScale);

        // Add alpha channel if needed
        Band.AddDefaultBands(ref tempTileImage, tile.BandsCount);

        // Insert tile
        return Image.Black(tile.Size.Width, tile.Size.Height).NewFromImage(new int[tile.BandsCount])
                    .Insert(tempTileImage, (int)writeArea.OriginCoordinate.X, (int)writeArea.OriginCoordinate.Y);
    }

    #endregion

    #region WriteTile

    /// <summary>
    /// Gets data from source <see cref="Image"/>
    /// or tile cache for specified <see cref="RasterTile"/>
    /// and writes it to ready file
    /// </summary>
    /// <param name="tileCache">Source <see cref="Image"/>
    /// or tile cache</param>
    /// <param name="tile">Target <see cref="RasterTile"/>
    /// <remarks><para/><see cref="Tile.Path"/> should not be null or whitespace</remarks></param>
    /// <exception cref="ArgumentNullException"/>
    public void WriteTileToFile(Image tileCache, RasterTile tile)
    {
        #region Preconditions checks

        if (tile == null) throw new ArgumentNullException(nameof(tile));

        CheckHelper.CheckFile(tile.Path, false);

        #endregion

        // Preconditions checked inside CreateTileImage, don't need to check anything here

        // Get postitions and sizes for current tile
        (Area readArea, Area writeArea)? areas = Area.GetAreas(this, tile);

        if (areas == null) return;

        (Area readArea, Area writeArea) = areas.Value;

        using Image tileImage = CreateTileImage(tileCache, tile, readArea, writeArea);

        tileImage.WriteToFile(tile.Path);
    }

    /// <inheritdoc cref="WriteTileToFile"/>
    public Task WriteTileToFileAsync(Image tileCache, RasterTile tile) =>
        Task.Run(() => WriteTileToFile(tileCache, tile));

    /// <summary>
    /// Gets data from source <see cref="Image"/>
    /// or tile cache for specified <see cref="RasterTile"/>
    /// and writes it to <see cref="IEnumerable{T}"/>
    /// </summary>
    /// <param name="tileCache">Source <see cref="Image"/>
    /// or tile cache</param>
    /// <param name="tile">Target <see cref="RasterTile"/></param>
    /// <returns><see cref="RasterTile"/>'s <see cref="byte"/>s</returns>
    public IEnumerable<byte> WriteTileToEnumerable(Image tileCache, RasterTile tile)
    {
        // Preconditions checked inside CreateTileImage, don't need to check anything here

        // Get postitions and sizes for current tile
        (Area readArea, Area writeArea)? areas = Area.GetAreas(this, tile);

        if (areas == null) return null;

        (Area readArea, Area writeArea) = areas.Value;

        using Image tileImage = CreateTileImage(tileCache, tile, readArea, writeArea);

        return tileImage.WriteToBuffer(tile.GetExtensionString());
    }

    /// <summary>
    /// Gets data from source <see cref="Image"/>
    /// or tile cache for specified <see cref="RasterTile"/>
    /// and writes it to <see cref="ChannelWriter{T}"/>
    /// </summary>
    /// <param name="tileCache">Source <see cref="Image"/>
    /// or tile cache</param>
    /// <param name="tile">Target <see cref="RasterTile"/></param>
    /// <param name="channelWriter">Target <see cref="ChannelWriter{T}"/></param>
    /// <returns><see langword="true"/> if <see cref="RasterTile"/> was written;
    /// <see langword="false"/> otherwise</returns>
    /// <exception cref="ArgumentNullException"/>
    public bool WriteTileToChannel(Image tileCache, RasterTile tile, ChannelWriter<RasterTile> channelWriter)
    {
        #region Preconditions checks

        // tileCache and interpolation checked inside WriteTileToEnumerable

        if (tile == null) throw new ArgumentNullException(nameof(tile));
        if (channelWriter == null) throw new ArgumentNullException(nameof(channelWriter));

        #endregion

        tile.Bytes = WriteTileToEnumerable(tileCache, tile);

        return tile.Bytes != null && tile.Validate(false) && channelWriter.TryWrite(tile);
    }

    /// <returns></returns>
    /// <inheritdoc cref="WriteTileToChannel"/>
    public ValueTask WriteTileToChannelAsync(Image tileCache, RasterTile tile, ChannelWriter<RasterTile> channelWriter)
    {
        #region Preconditions checks

        // tileCache and interpolation checked inside WriteTileToEnumerable

        if (tile == null) throw new ArgumentNullException(nameof(tile));
        if (channelWriter == null) throw new ArgumentNullException(nameof(channelWriter));

        #endregion

        tile.Bytes = WriteTileToEnumerable(tileCache, tile);

        return tile.Bytes != null && tile.Validate(false) ? channelWriter.WriteAsync(tile) : ValueTask.CompletedTask;
    }

    #endregion

    #region WriteTiles

    /// <summary>
    /// Crops current <see cref="RasterTile"/> on <see cref="RasterTile"/>s
    /// and writes them to <paramref name="outputDirectoryPath"/>
    /// </summary>
    /// <param name="outputDirectoryPath">Directory for output <see cref="RasterTile"/>s</param>
    /// <param name="tileExtension">Extension of ready <see cref="RasterTile"/>s
    /// <remarks><para/>.png by default</remarks></param>
    /// <inheritdoc cref="WriteTilesToAsyncEnumerable"/>
    /// <param name="minZ"></param>
    /// <param name="maxZ"></param>
    /// <param name="tmsCompatible"></param>
    /// <param name="tileSize"></param>
    /// <param name="interpolation"></param>
    /// <param name="bandsCount"></param>
    /// <param name="tileCacheCount"></param>
    /// <param name="threadsCount">T</param>
    /// <param name="progress"></param>
    /// <param name="printTimeAction"></param>
    /// <exception cref="ArgumentOutOfRangeException"/>
    /// <exception cref="RasterException"/>
    public void WriteTilesToDirectory(string outputDirectoryPath, int minZ, int maxZ, bool tmsCompatible = false,
                                      Size tileSize = null, TileExtension tileExtension = TileExtension.Png,
                                      NetVips.Enums.Kernel interpolation = NetVips.Enums.Kernel.Lanczos3,
                                      int bandsCount = RasterTile.DefaultBandsCount, int tileCacheCount = 1000,
                                      int threadsCount = 0, IProgress<double> progress = null,
                                      Action<string> printTimeAction = null)
    {
        #region Preconditions checks

        CheckHelper.CheckDirectory(outputDirectoryPath, true);
        // minZ and maxZ checked inside Number.GetCount
        tileSize ??= Tile.DefaultSize;

        // interpolation is checked on lower levels
        // bandsCount checked inside RasterTile ctor
        if (tileCacheCount <= 0) throw new ArgumentOutOfRangeException(nameof(tileCacheCount));

        ParallelOptions parallelOptions = new();
        if (threadsCount > 0) parallelOptions.MaxDegreeOfParallelism = threadsCount;

        // It's safe to set progress to null

        Stopwatch stopwatch = printTimeAction == null ? null : Stopwatch.StartNew();

        int tilesCount = Number.GetCount(MinCoordinate, MaxCoordinate, minZ, maxZ, tmsCompatible, tileSize);

        // if there's no tiles to crop
        if (tilesCount <= 0) throw new RasterException(Strings.NoTilesToCrop);

        double counter = 0.0;

        #endregion

        // Create tile cache to read data from it
        using Image tileCache = Data.Tilecache(tileSize.Width, tileSize.Height, tileCacheCount, threaded: true);

        void MakeTile(int x, int y, int z)
        {
            // Create directories for the tile
            // The overall structure looks like: outputDirectory/z/x/y.extension
            string tileDirectoryPath = Path.Combine(outputDirectoryPath, $"{z}", $"{x}");
            CheckHelper.CheckDirectory(tileDirectoryPath);

            Number tileNumber = new(x, y, z);
            RasterTile tile = new(tileNumber, GeoCoordinateSystem, tileSize, tmsCompatible)
            {
                Extension = tileExtension, BandsCount = bandsCount, Interpolation = interpolation
            };

            // Important: OpenLayers requires replacement of tileY to tileY+1
            tile.Path = Path.Combine(tileDirectoryPath, $"{y}{tile.GetExtensionString()}");

            // tile is validated inside of WriteTileToFile
            // ReSharper disable once AccessToDisposedClosure
            WriteTileToFile(tileCache, tile);

            // Report progress
            counter++;
            double percentage = counter / tilesCount * 100.0;
            progress?.Report(percentage);

            ProgressHelper.PrintEstimatedTimeLeft(percentage, stopwatch, printTimeAction);
        }

        // For each zoom
        for (int zoom = minZ; zoom <= maxZ; zoom++)
        {
            // Get tiles min/max numbers
            (Number minNumber, Number maxNumber) = GeoCoordinate.GetNumbers(MinCoordinate, MaxCoordinate,
                                                                            zoom, tileSize, tmsCompatible);

            // For each tile on given zoom calculate positions/sizes and save as file
            for (int tileY = minNumber.Y; tileY <= maxNumber.Y; tileY++)
            {
                int y = tileY;
                int z = zoom;

                Parallel.For(minNumber.X, maxNumber.X + 1, parallelOptions, x => MakeTile(x, y, z));
            }
        }
    }

    /// <inheritdoc cref="WriteTilesToDirectory"/>
    public Task WriteTilesToDirectoryAsync(string outputDirectoryPath, int minZ, int maxZ, bool tmsCompatible = false,
                                           Size tileSize = null, TileExtension tileExtension = TileExtension.Png,
                                           NetVips.Enums.Kernel interpolation = NetVips.Enums.Kernel.Lanczos3,
                                           int bandsCount = RasterTile.DefaultBandsCount, int tileCacheCount = 1000,
                                           int threadsCount = 0, IProgress<double> progress = null,
                                           Action<string> printTimeAction = null) =>
        Task.Run(() => WriteTilesToDirectory(outputDirectoryPath, minZ, maxZ, tmsCompatible, tileSize, tileExtension,
                                             interpolation, bandsCount, tileCacheCount, threadsCount, progress,
                                             printTimeAction));

    /// <summary>
    /// Crops current <see cref="Raster"/> on <see cref="RasterTile"/>s
    /// and writes them to <paramref name="channelWriter"/>
    /// </summary>
    /// <param name="channelWriter"><see cref="Channel"/> to write <see cref="RasterTile"/> to</param>
    /// <inheritdoc cref="WriteTilesToAsyncEnumerable"/>
    /// <param name="minZ"></param>
    /// <param name="maxZ"></param>
    /// <param name="tmsCompatible"></param>
    /// <param name="tileSize"></param>
    /// <param name="interpolation"></param>
    /// <param name="bandsCount"></param>
    /// <param name="tileCacheCount"></param>
    /// <param name="threadsCount"></param>
    /// <param name="progress"></param>
    /// <param name="printTimeAction"></param>
    /// <exception cref="ArgumentOutOfRangeException"/>
    /// <exception cref="RasterException"/>
    public void WriteTilesToChannel(ChannelWriter<RasterTile> channelWriter, int minZ, int maxZ,
                                    bool tmsCompatible = false, Size tileSize = null,
                                    NetVips.Enums.Kernel interpolation = NetVips.Enums.Kernel.Lanczos3,
                                    int bandsCount = RasterTile.DefaultBandsCount, int tileCacheCount = 1000,
                                    int threadsCount = 0, IProgress<double> progress = null,
                                    Action<string> printTimeAction = null)
    {
        #region Preconditions checks

        // channelWriter is checked on lower levels
        // minZ and maxZ checked inside Number.GetCount
        tileSize ??= Tile.DefaultSize;

        // interpolation is checked on lower levels
        // bandsCount checked inside RasterTile ctor
        if (tileCacheCount <= 0) throw new ArgumentOutOfRangeException(nameof(tileCacheCount));

        ParallelOptions parallelOptions = new();
        if (threadsCount > 0) parallelOptions.MaxDegreeOfParallelism = threadsCount;

        // It's safe to set progress to null

        Stopwatch stopwatch = printTimeAction == null ? null : Stopwatch.StartNew();

        int tilesCount = Number.GetCount(MinCoordinate, MaxCoordinate, minZ, maxZ, tmsCompatible, tileSize);

        // if there's no tiles to crop
        if (tilesCount <= 0) throw new RasterException(Strings.NoTilesToCrop);

        double counter = 0.0;

        #endregion

        // Create tile cache to read data from it
        using Image tileCache = Data.Tilecache(tileSize.Width, tileSize.Height, tileCacheCount, threaded: true);

        void MakeTile(int x, int y, int z)
        {
            Number tileNumber = new(x, y, z);
            RasterTile tile =
                new(tileNumber, GeoCoordinateSystem, tileSize, tmsCompatible)
                {
                    BandsCount = bandsCount, Interpolation = interpolation
                };

            // Should not throw exception if tile was skipped
            // ReSharper disable once AccessToDisposedClosure
            if (!WriteTileToChannel(tileCache, tile, channelWriter)) return;

            // Report progress
            counter++;
            double percentage = counter / tilesCount * 100.0;
            progress?.Report(percentage);

            ProgressHelper.PrintEstimatedTimeLeft(percentage, stopwatch, printTimeAction);
        }

        // For each zoom
        for (int zoom = minZ; zoom <= maxZ; zoom++)
        {
            // Get tiles min/max numbers
            (Number minNumber, Number maxNumber) = GeoCoordinate.GetNumbers(MinCoordinate, MaxCoordinate,
                                                                            zoom, tileSize, tmsCompatible);

            // For each tile on given zoom calculate positions/sizes and save as file
            for (int tileY = minNumber.Y; tileY <= maxNumber.Y; tileY++)
            {
                int y = tileY;
                int z = zoom;

                Parallel.For(minNumber.X, maxNumber.X + 1, parallelOptions, x => MakeTile(x, y, z));
            }
        }
    }

    /// <inheritdoc cref="WriteTilesToChannel"/>
    public Task WriteTilesToChannelAsync(ChannelWriter<RasterTile> channelWriter, int minZ, int maxZ,
                                         bool tmsCompatible = false, Size tileSize = null,
                                         NetVips.Enums.Kernel interpolation = NetVips.Enums.Kernel.Lanczos3,
                                         int bandsCount = RasterTile.DefaultBandsCount, int tileCacheCount = 1000,
                                         int threadsCount = 0, IProgress<double> progress = null,
                                         Action<string> printTimeAction = null) =>
        Task.Run(() => WriteTilesToChannel(channelWriter, minZ, maxZ, tmsCompatible, tileSize, interpolation,
                                           bandsCount, tileCacheCount, threadsCount, progress, printTimeAction));

    /// <summary>
    /// Crops current <see cref="Raster"/> on <see cref="RasterTile"/>s
    /// and writes them to <see cref="IEnumerable{T}"/>
    /// </summary>
    /// <returns><see cref="IEnumerable{T}"/> of <see cref="RasterTile"/>s</returns>
    /// <inheritdoc cref="WriteTilesToAsyncEnumerable"/>
    public IEnumerable<RasterTile> WriteTilesToEnumerable(int minZ, int maxZ, bool tmsCompatible = false,
                                                          Size tileSize = null,
                                                          NetVips.Enums.Kernel interpolation =
                                                              NetVips.Enums.Kernel.Lanczos3,
                                                          int bandsCount = RasterTile.DefaultBandsCount,
                                                          int tileCacheCount = 1000, IProgress<double> progress = null,
                                                          Action<string> printTimeAction = null)
    {
        #region Preconditions checks

        // minZ and maxZ checked inside Number.GetCount
        tileSize ??= Tile.DefaultSize;

        // interpolation is checked on lower levels
        // bandsCount checked inside RasterTile ctor
        if (tileCacheCount <= 0) throw new ArgumentOutOfRangeException(nameof(tileCacheCount));

        // It's safe to set progress to null

        Stopwatch stopwatch = printTimeAction == null ? null : Stopwatch.StartNew();

        int tilesCount = Number.GetCount(MinCoordinate, MaxCoordinate, minZ, maxZ, tmsCompatible, tileSize);

        // if there's no tiles to crop
        if (tilesCount <= 0) throw new RasterException(Strings.NoTilesToCrop);

        double counter = 0.0;

        #endregion

        // Create tile cache to read data from it
        using Image tileCache = Data.Tilecache(tileSize.Width, tileSize.Height, tileCacheCount, threaded: true);

        RasterTile MakeTile(int x, int y, int z)
        {
            Number tileNumber = new(x, y, z);
            RasterTile tile =
                new(tileNumber, GeoCoordinateSystem, tileSize, tmsCompatible)
                {
                    BandsCount = bandsCount, Interpolation = interpolation
                };

            tile.Bytes = WriteTileToEnumerable(tileCache, tile);

            // Report progress
            counter++;
            double percentage = counter / tilesCount * 100.0;
            progress?.Report(percentage);

            ProgressHelper.PrintEstimatedTimeLeft(percentage, stopwatch, printTimeAction);

            return tile;
        }

        // For each specified zoom
        for (int zoom = minZ; zoom <= maxZ; zoom++)
        {
            // Get tiles min/max numbers
            (Number minNumber, Number maxNumber) = GeoCoordinate.GetNumbers(MinCoordinate, MaxCoordinate,
                                                                            zoom, tileSize, tmsCompatible);

            // For each tile on given zoom calculate positions/sizes and save as file
            for (int tileY = minNumber.Y; tileY <= maxNumber.Y; tileY++)
            {
                for (int tileX = minNumber.X; tileX <= maxNumber.X; tileX++) yield return MakeTile(tileX, tileY, zoom);
            }
        }
    }

    /// <summary>
    /// Crops current <see cref="Raster"/> on <see cref="RasterTile"/>s
    /// and writes them to <see cref="IAsyncEnumerable{T}"/>
    /// </summary>
    /// <param name="minZ">Minimum cropped zoom
    /// <remarks><para/>Should be >= 0 and lesser or equal, than <paramref name="maxZ"/>
    /// </remarks></param>
    /// <param name="maxZ">Maximum cropped zoom
    /// <remarks><para/>Should be >= 0 and bigger or equal, than <paramref name="minZ"/>
    /// </remarks></param>
    /// <param name="tmsCompatible">Do you want to create tms-compatible <see cref="ITile"/>s?
    /// <remarks><para/><see langword="false"/> by default</remarks></param>
    /// <param name="tileSize"><see cref="Images.Size"/> of <see cref="ITile"/>s
    /// <remarks><para/>256x256 by default</remarks></param>
    /// <param name="interpolation">Interpolation of ready tiles
    /// <remarks><para/><see cref="NetVips.Enums.Kernel.Lanczos3"/> by default</remarks></param>
    /// <param name="bandsCount">Count of <see cref="Band"/>s in ready <see cref="ITile"/>s
    /// <remarks><para/>4 by default</remarks></param>
    /// <param name="tileCacheCount">Count of <see cref="ITile"/> to be in cache
    /// <remarks><para/>1000 by default</remarks></param>
    /// <param name="threadsCount">Threads count
    /// <remarks><para/>Calculates automatically by default</remarks></param>
    /// <param name="progress">Progress-reporter
    /// <remarks><para/><see langword="null"/> by default</remarks></param>
    /// <param name="printTimeAction"><see cref="Action{T}"/> to print estimated time
    /// <remarks><para/><see langword="null"/> by default;
    /// set to <see langword="null"/> if you don't want output</remarks></param>
    /// <returns><see cref="IAsyncEnumerable{T}"/> of <see cref="RasterTile"/>s</returns>
    /// <exception cref="ArgumentOutOfRangeException"/>
    /// <exception cref="RasterException"/>
    public IAsyncEnumerable<RasterTile> WriteTilesToAsyncEnumerable(int minZ, int maxZ, bool tmsCompatible = false,
                                                                    Size tileSize = null,
                                                                    NetVips.Enums.Kernel interpolation =
                                                                        NetVips.Enums.Kernel.Lanczos3,
                                                                    int bandsCount = RasterTile.DefaultBandsCount,
                                                                    int tileCacheCount = 1000, int threadsCount = 0,
                                                                    IProgress<double> progress = null,
                                                                    Action<string> printTimeAction = null)
    {
        // All preconditions checks are done in WriteTilesToChannelAsync method

        Channel<RasterTile> channel = Channel.CreateUnbounded<RasterTile>();

        WriteTilesToChannelAsync(channel.Writer, minZ, maxZ, tmsCompatible, tileSize, interpolation, bandsCount,
                                 tileCacheCount, threadsCount, progress, printTimeAction)
           .ContinueWith(_ => channel.Writer.Complete(), TaskScheduler.Current);

        return channel.Reader.ReadAllAsync();
    }

    #endregion

    #region Join tiles

    #region Create overview tiles

    /// <summary>
    /// Create overview <see cref="RasterTile"/>s for specified
    /// <see cref="GeoCoordinate"/>s using finding lower tiles inside
    /// <see cref="HashSet{T}"/> of <see cref="RasterTile"/>s
    /// </summary>
    /// <inheritdoc cref="JoinTilesIntoBytes"/>
    /// <param name="channelWriter"><see cref="Channel{T}"/> to write
    /// <see cref="RasterTile"/>s</param>
    /// <param name="minZ">Minimal overview zoom</param>
    /// <param name="maxZ">Maximal overview zoom</param>
    /// <param name="tiles">Input <see cref="RasterTile"/>s from which
    /// overview will be created</param>
    /// <param name="coordinateSystem">Target <see cref="RasterTile"/>s coordinate system</param>
    /// <param name="isBuffered">Is input <see cref="RasterTile"/>s contains
    /// data inside <see cref="ITile.Bytes"/> property?
    /// <remarks><para/>If set to <see langword="false"/>, will use
    /// <see cref="ITile.Path"/> to get input tiles's data</remarks></param>
    /// <param name="tileSize"></param>
    /// <param name="extension"></param>
    /// <param name="tmsCompatible">Are ready <see cref="RasterTile"/>s tms-compatible?
    /// <remarks><para/><see langword="false"/> by default</remarks></param>
    /// <param name="bandsCount"></param>
    /// <exception cref="ArgumentNullException"/>
    /// <exception cref="ArgumentOutOfRangeException"/>
    public void CreateOverviewTiles(ChannelWriter<RasterTile> channelWriter, int minZ, int maxZ,
                                    HashSet<RasterTile> tiles, bool isBuffered, CoordinateSystem coordinateSystem,
                                    Size tileSize = null, TileExtension extension = TileExtension.Png,
                                    bool tmsCompatible = false, int bandsCount = 4)
    {
        #region Preconditions checks

        if (channelWriter == null) throw new ArgumentNullException(nameof(channelWriter));
        if (minZ < 0) throw new ArgumentOutOfRangeException(nameof(minZ));
        if (maxZ < minZ) throw new ArgumentOutOfRangeException(nameof(maxZ));

        #endregion

        for (int z = minZ; z <= maxZ; z++)
        {
            (Number minNumber, Number maxNumber) =
                GeoCoordinate.GetNumbers(MinCoordinate, MaxCoordinate, z, Tile.DefaultSize, false);

            for (int x = minNumber.X; x <= maxNumber.X; x++)
            {
                int x1 = x;
                int z1 = z;
                Parallel.For(minNumber.Y, maxNumber.Y + 1, y =>
                {
                    Number number = new(x1, y, z1);

                    RasterTile tile =
                        new(number, coordinateSystem, tileSize, tmsCompatible)
                        {
                            Extension = extension, BandsCount = bandsCount
                        };
                    CreateOverviewTile(ref tile, tiles, isBuffered);

                    channelWriter.TryWrite(tile);
                });
            }
        }
    }

    /// <inheritdoc cref="CreateOverviewTiles"/>
    public Task CreateOverviewTilesAsync(ChannelWriter<RasterTile> channelWriter, int minZ, int maxZ,
                                         HashSet<RasterTile> tiles, bool isBuffered, CoordinateSystem coordinateSystem,
                                         Size tileSize = null, TileExtension extension = TileExtension.Png,
                                         bool tmsCompatible = false, int bandsCount = 4) =>
        Task.Run(() => CreateOverviewTiles(channelWriter, minZ, maxZ, tiles, isBuffered, coordinateSystem, tileSize,
                                           extension, tmsCompatible, bandsCount));

    #endregion

    #region Create overview tile

    /// <summary>
    /// Creates specified overview <see cref="RasterTile"/>
    /// from array of lower <see cref="RasterTile"/>s
    /// </summary>
    /// <param name="targetTile">Target <see cref="RasterTile"/></param>
    /// <param name="baseTiles">Collection of lower <see cref="RasterTile"/>s</param>
    /// <param name="isBuffered">Is input <see cref="RasterTile"/>s contains
    /// data inside <see cref="ITile.Bytes"/> property?
    /// <remarks><para/>If set to <see langword="false"/>, will use
    /// <see cref="ITile.Path"/> to get input tiles's data</remarks></param>
    /// <exception cref="ArgumentNullException"/>
    public static void CreateOverviewTile(ref RasterTile targetTile, HashSet<RasterTile> baseTiles, bool isBuffered)
    {
        #region Preconditions checks

        if (targetTile == null) throw new ArgumentNullException(nameof(targetTile));
        if (baseTiles == null) throw new ArgumentNullException(nameof(baseTiles));

        #endregion

        Number[] numbers = targetTile.Number.GetLowerNumbers();

        using RasterTile lower0 = baseTiles.FirstOrDefault(t => t.Number == numbers[0]);
        using RasterTile lower1 = baseTiles.FirstOrDefault(t => t.Number == numbers[1]);
        using RasterTile lower2 = baseTiles.FirstOrDefault(t => t.Number == numbers[2]);
        using RasterTile lower3 = baseTiles.FirstOrDefault(t => t.Number == numbers[3]);

        CreateOverviewTile(ref targetTile, lower0, lower1, lower2, lower3, isBuffered);
    }

    /// <summary>
    /// Creates specified overview <see cref="RasterTile"/>
    /// from 4 lower <see cref="RasterTile"/>s
    /// </summary>
    /// <inheritdoc cref="JoinTilesIntoImage(RasterTile,RasterTile,RasterTile,RasterTile,bool,Images.Size,int)"/>
    /// <param name="targetTile">Target <see cref="RasterTile"/></param>
    /// <param name="tile0"></param>
    /// <param name="tile1"></param>
    /// <param name="tile2"></param>
    /// <param name="tile3"></param>
    /// <param name="isBuffered"></param>
    /// <exception cref="ArgumentNullException"/>
    public static void CreateOverviewTile(ref RasterTile targetTile, RasterTile tile0, RasterTile tile1,
                                          RasterTile tile2, RasterTile tile3, bool isBuffered)
    {
        #region Preconditions checks

        if (targetTile == null) throw new ArgumentNullException(nameof(targetTile));

        #endregion

        targetTile.Bytes = JoinTilesIntoBytes(tile0, tile1, tile2, tile3, isBuffered, targetTile.Size,
                                              targetTile.BandsCount, targetTile.Extension);
    }

    #endregion

    #region Join tiles into bytes

    /// <summary>
    /// Join 4 <see cref="RasterTile"/>s into
    /// collection of <see cref="byte"/>s
    /// <para/>if all <see cref="RasterTile"/>s are
    /// <see langword="null"/> -- returns <see langword="null"/>
    /// </summary>
    /// <returns>Collection of ready image's <see cref="byte"/>s</returns>
    /// <inheritdoc cref="JoinTilesIntoImage(RasterTile,RasterTile,RasterTile,RasterTile,bool,Images.Size,int)"/>
    /// <param name="tile0"></param>
    /// <param name="tile1"></param>
    /// <param name="tile2"></param>
    /// <param name="tile3"></param>
    /// <param name="isBuffered"></param>
    /// <param name="tileSize"></param>
    /// <param name="bandsCount"></param>
    /// <param name="extension"><see cref="TileExtension"/> of ready <see cref="RasterTile"/></param>
    public static IEnumerable<byte> JoinTilesIntoBytes(RasterTile tile0, RasterTile tile1, RasterTile tile2,
                                                       RasterTile tile3, bool isBuffered, Size tileSize, int bandsCount,
                                                       TileExtension extension)
    {
        Image image = JoinTilesIntoImage(tile0, tile1, tile2, tile3, isBuffered, tileSize, bandsCount);

        byte[] result = image?.WriteToBuffer(Tile.GetExtensionString(extension));
        image?.Dispose();

        return result;
    }

    #endregion

    #region Join tiles into image

    /// <summary>
    /// Join 4 <see cref="RasterTile"/>s into
    /// one <see cref="Image"/>;
    /// <para/>if all <see cref="RasterTile"/>s are
    /// <see langword="null"/> -- returns <see langword="null"/>
    /// </summary>
    /// <returns>Ready <see cref="Image"/></returns>
    /// <param name="tile0">Upper-left <see cref="RasterTile"/>
    /// <remarks><para/>if set to <see langword="null"/>,
    /// empty tile will be created</remarks></param>
    /// <param name="tile1">Upper-right <see cref="RasterTile"/>
    /// <remarks><para/>if set to <see langword="null"/>,
    /// empty tile will be created</remarks></param>
    /// <param name="tile2">Lower-left <see cref="RasterTile"/>
    /// <remarks><para/>if set to <see langword="null"/>,
    /// empty tile will be created</remarks></param>
    /// <param name="tile3">Lower-right <see cref="RasterTile"/>
    /// <remarks><para/>if set to <see langword="null"/>,
    /// empty tile will be created</remarks></param>
    /// <param name="isBuffered">Is input <see cref="RasterTile"/>s contains
    /// data inside <see cref="ITile.Bytes"/> property?
    /// <remarks><para/>If set to <see langword="false"/>, will use
    /// <see cref="ITile.Path"/> to get input tiles's data</remarks></param>
    /// <param name="tileSize"><see cref="Images.Size"/> of input
    /// and target <see cref="RasterTile"/></param>
    /// <param name="bandsCount">Count of bands in target <see cref="RasterTile"/>
    /// <remarks><para/>must be in range (0-5)</remarks></param>
    /// <returns>Ready <see cref="Image"/></returns>
    public static Image JoinTilesIntoImage(RasterTile tile0, RasterTile tile1, RasterTile tile2, RasterTile tile3,
                                           bool isBuffered, Size tileSize, int bandsCount)
    {
        // ReSharper disable once RemoveRedundantBraces
        if (isBuffered)
        {
            return JoinTilesIntoImage(tile0?.Bytes, tile1?.Bytes, tile2?.Bytes, tile3?.Bytes, tileSize, bandsCount);
        }

        return JoinTilesIntoImage(tile0?.Path, tile1?.Path, tile2?.Path, tile3?.Path, tileSize, bandsCount);
    }

    /// <inheritdoc cref="JoinTilesIntoImage(RasterTile,RasterTile,RasterTile,RasterTile,bool,Images.Size,int)"/>
    public static Task<Image> JoinTilesIntoImageAsync(RasterTile tile0, RasterTile tile1, RasterTile tile2,
                                                      RasterTile tile3, bool isBuffered, Size tileSize,
                                                      int bandsCount) =>
        Task.Run(() => JoinTilesIntoImage(tile0, tile1, tile2, tile3, isBuffered, tileSize, bandsCount));

    /// <summary>
    /// Join arrays of <see cref="byte"/> of
    /// 4 <see cref="RasterTile"/>s into
    /// one <see cref="Image"/>
    /// <remarks><para/>if all arrays are <see langword="null"/>
    /// or empty -- returns <see langword="null"/></remarks>
    /// </summary>
    /// <param name="tile0Bytes">Bytes of upper-left <see cref="RasterTile"/>
    /// <remarks><para/>if set to <see langword="null"/>,
    /// empty tile will be created</remarks></param>
    /// <param name="tile1Bytes">Bytes of upper-right <see cref="RasterTile"/>
    /// <remarks><para/>if set to <see langword="null"/>,
    /// empty tile will be created</remarks></param>
    /// <param name="tile2Bytes">Bytes of lower-left <see cref="RasterTile"/>
    /// <remarks><para/>if set to <see langword="null"/>,
    /// empty tile will be created</remarks></param>
    /// <param name="tile3Bytes">Bytes of lower-right <see cref="RasterTile"/>
    /// <remarks><para/>if set to <see langword="null"/>,
    /// empty tile will be created</remarks></param>
    /// <param name="tileSize"><see cref="Images.Size"/> of input
    /// and target <see cref="RasterTile"/></param>
    /// <param name="bandsCount">Count of bands in target <see cref="RasterTile"/>
    /// <remarks><para/>must be in range (0-5)</remarks></param>
    /// <returns>Ready <see cref="Image"/></returns>
    /// <exception cref="ArgumentNullException"/>
    /// <exception cref="ArgumentOutOfRangeException"/>
    public static Image JoinTilesIntoImage(IEnumerable<byte> tile0Bytes, IEnumerable<byte> tile1Bytes,
                                           IEnumerable<byte> tile2Bytes, IEnumerable<byte> tile3Bytes, Size tileSize,
                                           int bandsCount)
    {
        #region Preconditions checks

        if (tileSize == null) throw new ArgumentNullException(nameof(tileSize));
        if (bandsCount < 1 || bandsCount > 4) throw new ArgumentOutOfRangeException(nameof(bandsCount));

        #endregion

        byte[][] bytes = new byte[4][];
        bytes[0] = tile0Bytes?.ToArray() ?? Array.Empty<byte>();
        bytes[1] = tile1Bytes?.ToArray() ?? Array.Empty<byte>();
        bytes[2] = tile2Bytes?.ToArray() ?? Array.Empty<byte>();
        bytes[3] = tile3Bytes?.ToArray() ?? Array.Empty<byte>();

        Image[] images = new Image[4];

        Size size = new(tileSize.Width / 2, tileSize.Height / 2);
        bool empty = true;

        for (int i = 0; i < 4; i++)
        {
            if (bytes[i].Any())
            {
                empty = false;
                images[i] = Image.NewFromBuffer(bytes[i]).ThumbnailImage(size.Width, size.Height);
            }
            else { images[i] = Image.Black(size.Width, size.Height, bandsCount); }
        }

        return empty ? null : Image.Arrayjoin(images, 2);
    }

    /// <inheritdoc cref="JoinTilesIntoImage(IEnumerable{byte},IEnumerable{byte},IEnumerable{byte},IEnumerable{byte},Images.Size,int)"/>
    public static Task<Image> JoinTilesIntoImageAsync(IEnumerable<byte> tile0Bytes, IEnumerable<byte> tile1Bytes,
                                                      IEnumerable<byte> tile2Bytes, IEnumerable<byte> tile3Bytes,
                                                      Size tileSize, int bandsCount) =>
        Task.Run(() => JoinTilesIntoImage(tile0Bytes, tile1Bytes, tile2Bytes, tile3Bytes, tileSize, bandsCount));

    /// <summary>
    /// Join 4 <see cref="RasterTile"/>s into
    /// one <see cref="Image"/>
    /// <remarks><para/>if all 4 paths are <see langword="null"/>
    /// or empty <see cref="string"/>s -- returns <see langword="null"/></remarks>
    /// </summary>
    /// <param name="tile0Path">Path of upper-left <see cref="RasterTile"/>
    /// <remarks><para/>if set to <see langword="null"/>,
    /// empty tile will be created</remarks></param>
    /// <param name="tile1Path">Path of upper-right <see cref="RasterTile"/>
    /// <remarks><para/>if set to <see langword="null"/>,
    /// empty tile will be created</remarks></param>
    /// <param name="tile2Path">Path of lower-left <see cref="RasterTile"/>
    /// <remarks><para/>if set to <see langword="null"/>,
    /// empty tile will be created</remarks></param>
    /// <param name="tile3Path">Path of lower-right <see cref="RasterTile"/>
    /// <remarks><para/>if set to <see langword="null"/>,
    /// empty tile will be created</remarks></param>
    /// <param name="tileSize"><see cref="Images.Size"/> of input
    /// and target <see cref="RasterTile"/></param>
    /// <param name="bandsCount">Count of bands in target <see cref="RasterTile"/>
    /// <remarks><para/>must be in range (0-5)</remarks></param>
    /// <returns>Ready <see cref="Image"/></returns>
    /// <exception cref="ArgumentNullException"/>
    /// <exception cref="ArgumentOutOfRangeException"/>
    public static Image JoinTilesIntoImage(string tile0Path, string tile1Path, string tile2Path, string tile3Path,
                                           Size tileSize, int bandsCount)
    {
        #region Preconditions checks

        if (tileSize == null) throw new ArgumentNullException(nameof(tileSize));
        if (bandsCount < 1 || bandsCount > 4) throw new ArgumentOutOfRangeException(nameof(bandsCount));

        #endregion

        string[] paths = new string[4];
        paths[0] = string.IsNullOrWhiteSpace(tile0Path) ? string.Empty : tile0Path;
        paths[1] = string.IsNullOrWhiteSpace(tile1Path) ? string.Empty : tile1Path;
        paths[2] = string.IsNullOrWhiteSpace(tile2Path) ? string.Empty : tile2Path;
        paths[3] = string.IsNullOrWhiteSpace(tile3Path) ? string.Empty : tile3Path;

        Image[] images = new Image[4];

        Size size = new(tileSize.Width / 2, tileSize.Height / 2);
        bool empty = true;

        for (int i = 0; i < 4; i++)
        {
            if (File.Exists(paths[i]))
            {
                empty = false;

                // TODO: image not closing
                // See https://github.com/kleisauke/net-vips/issues/84
                //images[i] = Image.NewFromFile(paths[i], true).ThumbnailImage(size.Width, size.Height);

                byte[] bytes = File.ReadAllBytes(paths[i]);
                images[i] = Image.NewFromBuffer(bytes).ThumbnailImage(size.Width, size.Height);
            }
            else { images[i] = Image.Black(size.Width, size.Height, bandsCount); }
        }

        return empty ? null : Image.Arrayjoin(images, 2);
    }

    /// <inheritdoc cref="JoinTilesIntoImage(string,string,string,string,Images.Size,int)"/>
    public static Task<Image> JoinTilesIntoImageAsync(string tile0Path, string tile1Path, string tile2Path,
                                                      string tile3Path, Size tileSize, int bandsCount) =>
        Task.Run(() => JoinTilesIntoImage(tile0Path, tile1Path, tile2Path, tile3Path, tileSize, bandsCount));

    #endregion

    #endregion

    #endregion
}