Skip to content

Instantly share code, notes, and snippets.

@dblock
Last active December 11, 2015 17:48
Show Gist options
  • Save dblock/4636948 to your computer and use it in GitHub Desktop.
Save dblock/4636948 to your computer and use it in GitHub Desktop.
Tile images for SeaDragon.
# MIT License (c) Artsy Inc. 2013
# Deep Zoom module for generating Deep Zoom (DZI) tiles from a source image
# Borrows heavily from https://github.com/meso-unimpressed/deep_zoom_slicer
module DeepZoom
extend ActiveSupport::Concern
# Defaults
DEFAULT_TILE_SIZE = 512
DEFAULT_TILE_OVERLAP = 0
DEFAULT_QUALITY = 75
DEFAULT_TILE_FORMAT = "jpg"
included do
attr_accessor :tile_base_dir
field :tile_size, type: Integer
field :tile_overlap, type: Integer
field :tile_format, type: String
field :tile_store_path_base, type: String
field :max_tiled_height, type: Integer
field :max_tiled_width, type: Integer
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.
#
# Options:
# * source: Magick::Image, or filename of image to be used for tiling
# * quality: Image compression quality (default: 75)
# * format: Format for output tiles (default: "jpg")
# * size: Size, in pixels, for tile squares (default: 512)
# * overlap: Size, in pixels, of the overlap between tiles (default: 2)
# * overwrite: If true, overwrites existing tiles (default: false)
# * store_dir: Full directory in which to output tiles.
# * store_path: Base output directory for tiles, rooted in the application's
# public asset path. Overrides :store_dir
# * uploader: A CarrierWave uploader to be used for, e.g., uploading tiles
# to Fog. Specifying an :uploader will override :store_path AND :store_dir.
#
# A note on options[:source]: The source defaults to @magick_image, if
# defined. If @magick_image is undefined and no options[:source] is
# passed, an exception is raised.
#
# A note on store_path: By default, output tiles are stored in dztiles/ in
# in the same directory as the source image. If :store_path is specified,
# the tiles will be stored in the location of :store_path, which is rooted
# in the application's asset base path.
#
# Returns :tile_base_dir.
##
def tile!(options = {})
@tile_source = options[:source] || @magick_image
unless @tile_source
raise "Either @magick_image must exist or options[:source] must be specified"
end
@tile_source = Magick::Image.read(@tile_source)[0] if @tile_source.is_a?(String)
self.tile_size = options[:size] || DEFAULT_TILE_SIZE
self.tile_overlap = options[:overlap] || DEFAULT_TILE_OVERLAP
self.tile_format = options[:format] || DEFAULT_TILE_FORMAT
self.max_tiled_height = @tile_source.rows
self.max_tiled_width = @tile_source.columns
@tile_quality = options[:quality] || DEFAULT_QUALITY
@overwrite = options[:overwrite] || false
@uploader = options[:uploader]
@store_path = @uploader ?
File.join(dirname(@uploader.store_path), tile_subdir) :
options[:store_path]
if @uploader && @uploader._storage == CarrierWave::Storage::Fog
@tile_base_dir = default_store_dir
else
@tile_base_dir = @store_path ?
File.join(Rails.public_path, @store_path) :
options[:store_dir] || default_store_dir
end
if defined?(ImageUploader) && @uploader.is_a?(ImageUploader)
self.tile_store_path_base = nil
else
# Pathize :tile_store_path_base and ensure leading slash
self.tile_store_path_base = @store_path ?
@store_path.gsub(/^\/?/, "/") :
@tile_base_dir.gsub(Rails.public_path, "/")
end
slice!
finalize!
@tile_base_dir
end
def slice!
if File.directory?(@tile_base_dir)
msg = "Output directory #{@tile_base_dir} already exists!"
@overwrite ? Rails.logger.warn(msg) : raise(msg)
end
image = @tile_source.dup
orig_width, orig_height = image.columns, image.rows
# iterate over all levels (= zoom stages)
max_level(orig_width, orig_height).downto(0) do |level|
width, height = image.columns, image.rows
current_level_dir = File.join(@tile_base_dir, level.to_s)
FileUtils.mkdir_p(current_level_dir)
# iterate over columns
x, col_count = 0, 0
while x < width
# iterate over rows
y, row_count = 0, 0
while y < height
dest_path = File.join(current_level_dir, "#{col_count}_#{row_count}.#{self.tile_format}")
tile_width, tile_height = tile_dimensions(x, y, self.tile_size, self.tile_overlap)
save_cropped_image(image, dest_path, x, y, tile_width, tile_height, @tile_quality)
y += (tile_height - (2 * self.tile_overlap))
row_count += 1
end
x += (tile_width - (2 * self.tile_overlap))
col_count += 1
end
image.resize!(0.5)
end
image.destroy!
end
# Store image with the specified :uploader
def finalize!
return unless @uploader
if @uploader._storage == CarrierWave::Storage::Fog
Dir.glob(File.join(@tile_base_dir, "*", "*")).each do |file|
shortname = file.split("/")[-3..-1].join("/")
fogfile = CarrierWave::Storage::Fog::File.new(
@uploader,
CarrierWave::Storage::Fog.new(@uploader),
File.join(dirname(@store_path), shortname),
)
fogfile.content_type = MIME::Types.type_for(file)[0].to_s
fogfile.store(CarrierWave::SanitizedFile.new(file))
end
remove_files!
end
end
def remove_files!
files_existed = File.directory?(@tile_base_dir)
DeepZoom.safe_remove_tile_dir @tile_base_dir if File.directory? @tile_base_dir
return files_existed
end
# Including classes can override these next two methods to inform the
# convenience method default_deep_zoom_attributes() of the expected values
# for max tiled height and width
def default_max_tiled_height
nil
end
def default_max_tiled_width
nil
end
def default_deep_zoom_attributes
{
tile_size: DEFAULT_TILE_SIZE,
tile_overlap: DEFAULT_TILE_OVERLAP,
tile_format: DEFAULT_TILE_FORMAT,
tile_store_path_base: nil,
max_tiled_height: default_max_tiled_height,
max_tiled_width: default_max_tiled_width
}
end
def set_default_deep_zoom_attributes!
self.update_attributes! default_deep_zoom_attributes
end
def tile_subdir(tile_size = self.tile_size, tile_overlap = self.tile_overlap)
# Unique directory name for each distinct tile set on the same zoomable object
tile_size && tile_overlap ? "dztiles-#{tile_size}-#{tile_overlap}" : "dztiles"
end
def tile_base_url
# If :tile_store_path_base is undefined, fall back to ImageUploader defaults
return nil unless self.tile_size && self.tile_overlap
"#{DeepZoom.tile_url_prefix}/#{self.tile_store_path_base || ImageUploader.store_path_base(self) + tile_subdir}"
end
def self.tile_url_prefix
Rails.application.wildcard_asset_host ENV['TILE_IMAGES_URL'] || ImageUploader.image_url_prefix
end
def self.safe_remove_tile_dir(tile_dir)
# Carefully remove temporary dztiles directories and their contents...
if tile_dir && File.directory?(tile_dir)
Dir.glob(File.join(tile_dir, "*", "*_*.jpg")).each do |tile_file|
FileUtils.rm(tile_file)
end
Dir.glob(File.join(tile_dir, "*")).each do |subdir|
FileUtils.rmdir subdir if File.directory?(subdir)
end
FileUtils.rmdir tile_dir
end
end
protected
def dirname(file)
case file
when String
File.dirname(file)
when CarrierWave::SanitizedFile, CarrierWave::Storage::Fog::File
File.dirname(file.path)
end
end
def default_store_dir
File.join(dirname(@tile_source.filename), tile_subdir)
end
# 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
return 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)
return (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 = 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)
cropped.write(dest) { self.quality = quality }
end
# Returns filename (without path and extension) and its extension as array.
# path/to/file.txt -> ['file', 'txt']
def split_to_filename_and_extension(path)
extension = File.extname(path).gsub('.', '')
filename = File.basename(path, '.' + extension)
return filename, extension
end
end
@dblock
Copy link
Author

dblock commented Mar 17, 2014

This is now a library and command-line tool: https://github.com/dblock/dzt.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment