lib/dzt/tiler.rb

Summary

Maintainability
A
2 hrs
Test Coverage
# Deep Zoom module for generating Deep Zoom (DZI) tiles from a source image
require_relative 'file_storage'
require_relative 's3_storage'
module DZT
  class Tiler
    # Defaults
    DEFAULT_TILE_SIZE = 512
    DEFAULT_TILE_OVERLAP = 0
    DEFAULT_QUALITY = 75
    DEFAULT_TILE_FORMAT = 'jpg'
    DEFAULT_OVERWRITE_FLAG = false

    # Generates the DZI-formatted tiles and sets necessary metadata on this object.
    #
    # @param source Magick::Image, or filename of image to be used for tiling
    # @param quality Image compression quality (default: 75)
    # @param format Format for output tiles (default: "jpg")
    # @param size Size, in pixels, for tile squares (default: 512)
    # @param overlap Size, in pixels, of the overlap between tiles (default: 2)
    # @param overwrite Whether or not to overwrite if the destination exists (default: false)
    # @param storage Either an instance of S3Storage or FileStorage
    #
    def initialize(options)
      @tile_source = options[:source]
      fail 'Missing options[:source].' unless @tile_source

      @tile_source = Magick::Image.read(@tile_source)[0] if @tile_source.is_a?(String)
      @tile_size = options[:size] || DEFAULT_TILE_SIZE
      @tile_overlap = options[:overlap] || DEFAULT_TILE_OVERLAP
      @tile_format  = options[:format] || DEFAULT_TILE_FORMAT

      @max_tiled_height = @tile_source.rows
      @max_tiled_width = @tile_source.columns

      @tile_quality = options[:quality] || DEFAULT_QUALITY
      @overwrite = options[:overwrite] || DEFAULT_OVERWRITE_FLAG
      @storage = options[:storage]
    end

    ##
    # Generates the DZI-formatted tiles and sets necessary metadata on this object.
    # Uses a default tile size of 512 pixels, with a default overlap of 2 pixel.
    ##
    def slice!(&_block)
      fail "Output #{@destination} already exists!" if ! @overwrite && @storage.exists?

      image = @tile_source.dup
      orig_width = image.columns
      orig_height = image.rows

      # iterate over all levels (= zoom stages)
      max_level(orig_width, orig_height).downto(0) do |level|
        width = image.columns
        height = image.rows

        current_level_storage_dir = @storage.storage_location(level)
        @storage.mkdir(current_level_storage_dir)
        yield current_level_storage_dir if block_given?

        # iterate over columns
        x = 0
        col_count = 0
        while x < width
          # iterate over rows
          y = 0
          row_count = 0
          while y < height
            dest_path = File.join(current_level_storage_dir, "#{col_count}_#{row_count}.#{@tile_format}")
            tile_width, tile_height = tile_dimensions(x, y, @tile_size, @tile_overlap)

            save_cropped_image(image, dest_path, x, y, tile_width, tile_height, @tile_quality)

            y += (tile_height - (2 * @tile_overlap))
            row_count += 1
          end
          x += (tile_width - (2 * @tile_overlap))
          col_count += 1
        end

        image.resize!(0.5)
      end

      image.destroy!
    end

    protected

    # Determines width and height for tiles, dependent of tile position.
    # Center tiles have overlapping on each side.
    # Borders have no overlapping on the border side and overlapping on all other sides.
    # Corners have only overlapping on the right and lower border.
    def tile_dimensions(x, y, tile_size, overlap)
      overlapping_tile_size = tile_size + (2 * overlap)
      border_tile_size      = tile_size + overlap

      tile_width  = (x > 0) ? overlapping_tile_size : border_tile_size
      tile_height = (y > 0) ? overlapping_tile_size : border_tile_size

      [tile_width, tile_height]
    end

    # Calculates how often an image with given dimension can
    # be divided by two until 1x1 px are reached.
    def max_level(width, height)
      (Math.log([width, height].max) / Math.log(2)).ceil
    end

    # Crops part of src image and writes it to dest path.
    #
    # Params: src: may be an Magick::Image object or a path to an image.
    #         dest: path where cropped image should be stored.
    #         x, y: offset from upper left corner of source image.
    #         width, height: width and height of cropped image.
    #         quality: compression level 0-100 (or 0.0-1.0), lower number means higher compression.
    def save_cropped_image(src, dest, x, y, width, height, quality = 75)
      if src.is_a? Magick::Image
        img = src
      else
        img = Magick::Image.read(src).first
      end

      quality *= 100 if quality < 1

      # The crop method retains the offset information in the cropped image.
      # To reset the offset data, adding true as the last argument to crop.
      cropped = img.crop(x, y, width, height, true)
      @storage.write(cropped, dest, quality: quality)
    end
  end
end