Last active
December 11, 2015 17:48
-
-
Save dblock/4636948 to your computer and use it in GitHub Desktop.
Tile images for SeaDragon.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This is now a library and command-line tool: https://github.com/dblock/dzt.