277 lines
11 KiB
Ruby
277 lines
11 KiB
Ruby
require 'open3'
|
|
require_relative './exceptions'
|
|
|
|
module CartoDB
|
|
module Importer2
|
|
class Raster2Pgsql
|
|
SCHEMA = 'cdb_importer'
|
|
PROJECTION = 3857
|
|
BLOCKSIZE = '128x128'
|
|
DOWNSAMPLED_FILENAME = '%s_downsampled.tif'
|
|
WEBMERCATOR_FILENAME = '%s_webmercator.tif'
|
|
ALIGNED_WEBMERCATOR_FILENAME = '%s_aligned_webmercator.tif'
|
|
SQL_FILENAME = '%s%s.sql'
|
|
OVERLAY_TABLENAME = 'o_%s_%s'
|
|
RASTER_COLUMN_NAME = 'the_raster_webmercator'
|
|
GDALWARP_COMMON_OPTIONS = ['-co', 'COMPRESS=LZW', '-co', 'BIGTIFF=IF_SAFER'].freeze
|
|
MAX_REDUCTED_SIZE = 256
|
|
|
|
def initialize(table_name, filepath, pg_options, db)
|
|
self.filepath = filepath
|
|
self.basepath = filepath.slice(0, filepath.rindex('/')+1)
|
|
self.webmercator_filepath = WEBMERCATOR_FILENAME % [ filepath.gsub(/\.tif$/, '') ]
|
|
self.aligned_filepath = ALIGNED_WEBMERCATOR_FILENAME % [ filepath.gsub(/\.tif$/, '') ]
|
|
self.downsampled_filepath = DOWNSAMPLED_FILENAME % [ filepath.gsub(/\.tif$/, '') ]
|
|
self.pg_options = pg_options
|
|
self.table_name = table_name
|
|
self.exit_code = nil
|
|
self.command_output = ''
|
|
self.additional_tables = []
|
|
self.db = db
|
|
self.base_table_fqtn = SCHEMA + '.' + table_name
|
|
end
|
|
|
|
attr_reader :exit_code, :command_output
|
|
|
|
def run
|
|
if need_downsample?
|
|
downsample_raster
|
|
reproject_raster(downsampled_filepath)
|
|
else
|
|
reproject_raster(filepath)
|
|
end
|
|
|
|
size = extract_raster_size
|
|
pixel_size = extract_pixel_size
|
|
|
|
overviews_list = calculate_raster_overviews(size)
|
|
scale = calculate_raster_scale(pixel_size)
|
|
|
|
align_raster(scale)
|
|
|
|
run_raster2pgsql(overviews_list)
|
|
|
|
add_raster_base_overview_for_tiler
|
|
import_original_raster
|
|
|
|
self
|
|
end
|
|
|
|
def need_downsample?
|
|
# There is no less than Byte type in GDAL Byte/Int16/UInt16/UInt32/Int32/Float32/Float64/CInt16/CInt32/CFloat32/CFloat64
|
|
bands_type = extract_bands_type
|
|
bands_type.select{|band| band.downcase!='byte'}.size > 0
|
|
end
|
|
|
|
# Returns a list of additional support tables created, that should go to the user DB
|
|
# but not get cartodbfied/shown on the dashboard
|
|
def additional_support_tables
|
|
additional_tables.map { |scale| OVERLAY_TABLENAME % [scale, table_name] }
|
|
end
|
|
|
|
private
|
|
|
|
attr_writer :exit_code, :command_output
|
|
attr_accessor :filepath, :pg_options, :table_name, :webmercator_filepath, :aligned_filepath, \
|
|
:basepath, :additional_tables, :db, :base_table_fqtn, :downsampled_filepath
|
|
|
|
def downsample_raster
|
|
gdal_translate_command = [gdal_translate_path, '-scale', '-ot', 'Byte', GDALWARP_COMMON_OPTIONS, filepath, downsampled_filepath].flatten
|
|
|
|
stdout, stderr, status = Open3.capture3(*gdal_translate_command)
|
|
output_message = "(#{status}) |#{stdout + stderr}| Command: #{gdal_translate_command}"
|
|
self.command_output << "\n#{output_message}"
|
|
self.exit_code = status.to_i
|
|
raise TiffToSqlConversionError.new(output_message) if status.to_i != 0
|
|
end
|
|
|
|
def reproject_raster(file)
|
|
gdalwarp_command =[gdalwarp_path, GDALWARP_COMMON_OPTIONS, '-t_srs', "EPSG:#{PROJECTION}", file, webmercator_filepath].flatten
|
|
|
|
stdout, stderr, status = Open3.capture3(*gdalwarp_command)
|
|
output_message = "(#{status}) |#{stdout + stderr}| Command: #{gdalwarp_command}"
|
|
self.command_output << "\n#{output_message}"
|
|
self.exit_code = status.to_i
|
|
raise TiffToSqlConversionError.new(output_message) if status.to_i != 0
|
|
end
|
|
|
|
def align_raster(scale)
|
|
gdalwarp_command = [gdalwarp_path, GDALWARP_COMMON_OPTIONS, '-tr', scale.to_s, "-#{scale}", webmercator_filepath, aligned_filepath].flatten
|
|
|
|
stdout, stderr, status = Open3.capture3(*gdalwarp_command)
|
|
output_message = "(#{status}) |#{stdout + stderr}| Command: #{gdalwarp_command}"
|
|
self.command_output << "\n#{output_message}"
|
|
self.exit_code = status.to_i
|
|
raise TiffToSqlConversionError.new(output_message) if status.to_i != 0
|
|
end
|
|
|
|
# Returns only X pixel size/scale
|
|
def extract_pixel_size
|
|
output_message = extract_raster_info(webmercator_filepath)
|
|
matches = output_message.match(/pixel size = \((.*)?,/i)
|
|
raise TiffToSqlConversionError.new("Error obtaining raster pixel size: #{output_message}") unless matches[1]
|
|
matches[1].to_f
|
|
end
|
|
|
|
def extract_raster_size
|
|
output_message = extract_raster_info(webmercator_filepath)
|
|
matches = output_message.match(/size is (.*)?\n/i)
|
|
raise TiffToSqlConversionError.new("Error obtaining raster size: #{output_message}") unless matches[1]
|
|
matches[1].split(', ')
|
|
.map{ |value| value.to_i }
|
|
end
|
|
|
|
# Returns the raster bands type: Byte, Float64...
|
|
def extract_bands_type
|
|
output_message = extract_raster_info(filepath)
|
|
matches = output_message.scan(/band \d* .* type=(\w*)/i)
|
|
raise TiffToSqlConversionError.new("Error obtaining band field type: #{output_message}") if matches.empty?
|
|
|
|
matches.flatten
|
|
end
|
|
|
|
def extract_raster_info(file)
|
|
gdalinfo_command = [gdalinfo_path, file]
|
|
|
|
stdout, stderr, status = Open3.capture3(*gdalinfo_command)
|
|
output_message = "(#{status}) |#{stdout + stderr}| Command: #{gdalinfo_command}"
|
|
self.command_output << "\n#{output_message}"
|
|
self.exit_code = status.to_i
|
|
raise TiffToSqlConversionError.new(output_message) if status.to_i != 0
|
|
|
|
return output_message
|
|
end
|
|
|
|
def run_raster2pgsql(overviews_list)
|
|
# We create a pipe and wait for it to complete the transaction in postgres
|
|
# In order to avoid a temporary sql, potentially pretty big
|
|
pipeline = [raster2pgsql_command(overviews_list), psql_base_command]
|
|
Open3.pipeline_r(*pipeline, err: :out) do |output_st, statuses|
|
|
status = statuses.last.value
|
|
output = output_st.read
|
|
output_message = "(#{status}) |#{output}| Command: #{pipeline}"
|
|
self.command_output << "\n#{output_message}"
|
|
self.exit_code = status.to_i
|
|
|
|
if output =~ /canceling statement due to statement timeout/i
|
|
raise StatementTimeoutError.new(output_message, ERRORS_MAP[StatementTimeoutError])
|
|
end
|
|
|
|
raise UnknownSridError.new(output_message) if output =~ /invalid srid/i
|
|
raise TiffToSqlConversionError.new(output_message) if status.to_i != 0
|
|
raise TiffToSqlConversionError.new(output_message) if output =~ /failure/i
|
|
end
|
|
end
|
|
|
|
def calculate_raster_scale(pixel_size)
|
|
z0 = 156543.03515625
|
|
|
|
factor = z0 / pixel_size
|
|
|
|
pw = Math::log(factor) / Math::log(2)
|
|
pow2 = pw.ceil
|
|
|
|
out_scale = z0 / (2 ** pow2)
|
|
|
|
out_scale - (out_scale * 0.0001)
|
|
end
|
|
|
|
def calculate_raster_overviews(raster_size)
|
|
bigger_size = raster_size.max
|
|
|
|
max_power =
|
|
if bigger_size > MAX_REDUCTED_SIZE
|
|
Math::log(bigger_size.to_f / MAX_REDUCTED_SIZE, 2).ceil
|
|
else
|
|
0
|
|
end
|
|
|
|
range = (1..max_power + 1)
|
|
|
|
overviews = range.map { |x| 2**x }.select { |x| x <= 1000 }
|
|
|
|
self.additional_tables = overviews
|
|
|
|
overviews.join(',')
|
|
end
|
|
|
|
def raster2pgsql_command(overviews_list)
|
|
[
|
|
raster2pgsql_path, '-s', PROJECTION.to_s, '-t', BLOCKSIZE, '-C', '-x', '-Y', '-I', '-f', RASTER_COLUMN_NAME,
|
|
'-l', overviews_list, aligned_filepath, "#{SCHEMA}.#{table_name}"
|
|
]
|
|
end
|
|
|
|
# We add an overview for the tiler with factor = 1,
|
|
# using the reprojected and adjusted base table. This is done so that the
|
|
# tiler will always use those overviews and never the base table that
|
|
# should be imported without any transformation to avoid
|
|
# reprojection/resampling artifacts in analysis.
|
|
def add_raster_base_overview_for_tiler
|
|
overview_name = OVERLAY_TABLENAME % [1, table_name]
|
|
overview_fqtn = SCHEMA + '.' + overview_name
|
|
db.run %{CREATE TABLE #{overview_fqtn} AS SELECT * FROM #{base_table_fqtn}}
|
|
db.run %{CREATE INDEX ON "#{SCHEMA}"."#{overview_name}" USING gist (st_convexhull("#{RASTER_COLUMN_NAME}"))}
|
|
db.run %{SELECT AddRasterConstraints('#{SCHEMA}', '#{overview_name}','#{RASTER_COLUMN_NAME}',TRUE,TRUE,TRUE,TRUE,TRUE,TRUE,FALSE,TRUE,TRUE,TRUE,TRUE,FALSE)}
|
|
db.run %{SELECT AddOverviewConstraints('#{SCHEMA}', '#{overview_name}'::name, '#{RASTER_COLUMN_NAME}'::name, '#{SCHEMA}', '#{table_name}'::name, '#{RASTER_COLUMN_NAME}'::name, 1)}
|
|
@additional_tables = [1] + @additional_tables
|
|
end
|
|
|
|
# Import the original raster file without reprojections, adjusting or scales.
|
|
# NOTE: the name of the column the_raster_webmercator is maintained for compatibility.
|
|
def import_original_raster
|
|
db.run %{DROP TABLE #{base_table_fqtn}}
|
|
raster_import_command = [raster2pgsql_path, '-t', BLOCKSIZE, '-C', '-x', '-Y', '-I', '-f', RASTER_COLUMN_NAME,
|
|
filepath, "#{SCHEMA}.#{table_name}"]
|
|
# TODO refactor with run_raster2pgsql
|
|
pipeline = [raster_import_command, psql_base_command]
|
|
Open3.pipeline_r(*pipeline, err: :out) do |output_st, statuses|
|
|
status = statuses.last.value
|
|
output = output_st.read
|
|
output_message = "(#{status}) |#{output}| Command: #{pipeline}"
|
|
self.command_output << "\n#{output_message}"
|
|
self.exit_code = status.to_i
|
|
|
|
if output =~ /canceling statement due to statement timeout/i
|
|
raise StatementTimeoutError.new(output_message, ERRORS_MAP[StatementTimeoutError])
|
|
end
|
|
|
|
raise UnknownSridError.new(output_message) if output =~ /invalid srid/i
|
|
raise TiffToSqlConversionError.new(output_message) if status.to_i != 0
|
|
raise TiffToSqlConversionError.new(output_message) if output =~ /failure/i
|
|
end
|
|
end
|
|
|
|
def psql_base_command
|
|
host = pg_options.fetch(:host)
|
|
port = pg_options.fetch(:port)
|
|
user = pg_options.fetch(:username)
|
|
database = pg_options.fetch(:database)
|
|
|
|
[psql_path, '-h', host, '-p', port.to_s, '-U', user, '-d', database]
|
|
end
|
|
|
|
def raster2pgsql_path
|
|
`which raster2pgsql`.strip
|
|
end
|
|
|
|
def psql_path
|
|
`which psql`.strip
|
|
end
|
|
|
|
def gdalwarp_path
|
|
`which gdalwarp`.strip
|
|
end
|
|
|
|
def gdal_translate_path
|
|
`which gdal_translate`.strip
|
|
end
|
|
|
|
def gdalinfo_path
|
|
`which gdalinfo`.strip
|
|
end
|
|
|
|
end
|
|
end
|
|
end
|