Geo::Transform 作りました

地図と測量の座標変換を手軽にしたい

Geotools (Java) でやたらとややこしいのが座標変換です。まず第一に、必要なクラスを見つけるのがものすごく難しいです。「奥が深い」と思わせるという意味で、バッドノウハウの温床になっています。ということで、座標変換系機能のグッドラッパーを目指す、Geo::Tranform を作ってみました。

実装詳細

Geo::import_epsg_crs(epsg_code) 座標系の EPSG コードを与えると、その座標系の Geotools 実装を返します。
Geo::Transform 座標変換器クラスです
Geo::Transform.new(srs_crs, dst_crs) 座標変換器のコンストラクタです。変換前の座標系、変換後の座標系という順番で引数を渡します。
Geo::Transform#transform(x, y) 変換前の座標系での座標 (x, y) を変換後の座標系の座標に変換したものを配列として返します。

Geotools で3次元の座標の変換はできなかったかどうか、あとで確認したいと思います。(当面3次元の座標変換を扱うつもりはないので、私の作業の中では優先度は低いです。)

動作チェック

ix = Geo::import_epsg_crs(2451)       # EPSG:2451 - 平面直角座標系 IX 系
wgs84 = Geo::import_epsg_crs(4326)    # EPSG:4326 - WGS84

t = Geo::Transform.new(ix, wgs84)     # IX 系から WGS84 への座標変換器
t_inv = Geo::Transform.new(wgs84, ix) # WGS84 から IX 系への座標変換器

pt = t.transform(0, 0)                 # IX 系の原点を WGS84 に座標変換
pt_inv = t_inv.transform(pt[0], pt[1]) # その点を IX 系に戻す。元に戻るか?

print "IX origin is #{pt.inspect} in WGS84\n"
print "#{pt_inv.inspect} must be (0, 0)\n"

に対し、

$ ruby geotools.rb
IX origin is [139.833333333333, 35.9999999991025] in WGS84
[0.0, 0.0] must be (0, 0)
$ jruby geotools.rb
IX origin is [139.8333333333333, 35.99999999910247] in WGS84
[0.0, 0.0] must be (0, 0)

という結果を戻してくれます。
この結果は、以前 Ruby + OGR で同様の計算をしたときの結果:

"POINT (139.833333333333428 36.000000000000036)"

http://d.hatena.ne.jp/hfu/20070704/1185663573

と同じと言ってよいと考えています。表示上の(?)細かな違いの評価と、その原因についてはあとで考えたいと思います。

実装

現時点での geotools.rb の実装は以下のようになっています。

# this code is under development and subject to major change.
require 'iconv'

module Geo
  # Geo::Tools module, to include nesessary classes from Geotools
  module Tools
    QUALIFIED_NAMES = %w{java.lang.String java.lang.Integer java.lang.Double java.lang.Long java.io.File org.geotools.data.shapefile.ShapefileDataStore org.geotools.feature.AttributeTypeFactory org.geotools.feature.FeatureTypeBuilder org.geotools.feature.type.GeometricAttributeType com.vividsolutions.jts.io.WKTReader org.geotools.referencing.crs.EPSGCRSAuthorityFactory org.geotools.referencing.operation.DefaultCoordinateOperationFactory org.geotools.geometry.DirectPosition2D}
    begin
      require 'rjb'
      QUALIFIED_NAMES.each do |qn|
        sn = qn.split('.').last
        module_eval "#{sn} = Rjb::import('#{qn}')"
      end
      IMPLEMENTATION = 'rjb'
    rescue LoadError
      require 'java'
      QUALIFIED_NAMES.each do |qn|
        include_class qn
      end
      IMPLEMENTATION = 'java'
    end
  end

  # Geo module variables
  @@wkt_reader = nil
  @@epsg_crs_authority_factory = nil

  # Geo module 'good-wrapper' / 'Grossklasstum' classes
  class Reader
    ## TODO: implement attribute whitelist filtering (for better performance)
    def Reader::foreach(shapefile, sjis_workaround = false)
      if(Tools::IMPLEMENTATION == 'java' && sjis_workaround)
        raise "sjis_workaround for JRuby is not implemented."
      end
      store = Tools::ShapefileDataStore.new(Tools::File.new(shapefile).toURL)
      iter = store.getFeatureSource.getFeatures.features
      feat_type = store.getFeatureSource.getSchema
      attr_names = []
      feat_type.getAttributeCount.times do |i|
        attr_names << feat_type.getAttributeType(i).getName
      end
      while(iter.hasNext)
        feat = iter.next
        attrs = {}
        feat.getNumberOfAttributes.times do |i|
          attr = feat.getAttribute(i)

          if Tools::IMPLEMENTATION == 'rjb'
            if attr.getClass.equals(Tools::Integer)
              attr = attr.intValue
            elsif attr.getClass.equals(Tools::Double)
              attr = attr.doubleValue
            elsif attr.getClass.equals(Tools::String)
              if sjis_workaround
                attr = Iconv.conv('UTF-8', 'Shift_JIS', attr.getBytes('iso-8859-1'))
              else
                attr = attr.toString
              end
            elsif attr.getClass.equals(Tools::Long)
              attr = attr.longValue
            end
          end

          attrs[attr_names[i]] = attr
        end
        attrs.delete('the_geom')
        yield feat.getDefaultGeometry, attrs
      end
      iter.close
    end
  end

  class Writer
    def Writer::open(shapefile)
      w = Writer.new(shapefile)
      yield w
      w.close
    end

    def initialize(shapefile)
      @shapefile = shapefile
      @writer = nil
      @first = true
    end
    
    def setup(geom, attrs)
      attrs.delete('the_geom')
      ftb = Tools::FeatureTypeBuilder.newInstance(@shapefile)
      attrs.each do |key, value|
        if value.methods.include?('_classname')
          attr_class = value.getClass
        elsif value.class == String
          attr_class = Tools::String
        elsif value.class == Fixnum
          attr_class = Tools::Integer
        elsif value.class == Float
          attr_class = Tools::Double
        else
          raise "attribute #{key} has unrecognizable class #{value.class}"
        end
        ftb.addType(Tools::AttributeTypeFactory.newAttributeType(key, attr_class))
      end
      if geom.class == String
        geom = import_wkt_geometry(geom)
      end
      ftb.setDefaultGeometry(Tools::GeometricAttributeType.new('the_geom', geom.getClass, true, nil, nil, nil))
      ft = ftb.getFeatureType
      store = Tools::ShapefileDataStore.new(Tools::File.new(@shapefile).toURL)
      store.createSchema(ft)
      @writer = store.getFeatureWriter(@shapefile, store.getFeatureSource(@shapefile).getTransaction)
      @first = false
    end
    private :setup

    def write(geom, attrs)
      setup(geom, attrs) if @first
      feat = @writer.next
      if geom.class == String
        geom = import_wkt_geometry(geom)
      end
      feat.setDefaultGeometry(geom)
      attrs.each do |key, value|
        feat.setAttribute(key, value)
      end
      @writer.write
    end

    def close
      @writer.close unless @writer == nil
    end
  end

  class Transform
    def initialize(src_crs, dst_crs)
      cof = Geo::Tools::DefaultCoordinateOperationFactory.new
      @co = cof.createOperation(src_crs, dst_crs)
      @mt = @co.getMathTransform
    end

    def transform(x, y) # z?
      r = Geo::Tools::DirectPosition2D.new
      @mt.transform(Geo::Tools::DirectPosition2D.new(x, y), r)
      return r.x, r.y
    end

    ## TODO: def transform(geom)
    ## TODO: accessor to @mt or @co
  end

  # Geo module convenient methods
  def Geo::import_wkt_geometry(wkt)
    @@wkt_reader = Geo::Tools::WKTReader.new if @@wkt_reader == nil
    @@wkt_reader.read(wkt)
  end

  def Geo::import_epsg_crs(epsg_code)
    @@epsg_crs_authority_factory = Geo::Tools::EPSGCRSAuthorityFactory.new if @@epsg_crs_authority_factory == nil
    if epsg_code.class == Fixnum
      return @@epsg_crs_authority_factory.createCoordinateReferenceSystem("EPSG:#{epsg_code}")
    elsif epsg_code.class == String
      return @@epsg_crs_authority_factory.createCoordinateReferenceSystem(epsg_code)
    else
      raise "Geo::import_epsg_crs: can not handle epsg_code = #{epsg_code}"
    end
  end

  def dms2dec(d, m, s)
    d + m / 60.0 + s / 3600.0
  end

  def dec2dms(dec)
    #TODO
    raise "not implemented."
  end
end

# ad hoc tests
## TODO: better separate tests as unit tests.
ix = Geo::import_epsg_crs(2451)       # EPSG:2451 - 平面直角座標系 IX 系
wgs84 = Geo::import_epsg_crs(4326)    # EPSG:4326 - WGS84

t = Geo::Transform.new(ix, wgs84)     # IX 系から WGS84 への座標変換器
t_inv = Geo::Transform.new(wgs84, ix) # WGS84 から IX 系への座標変換器

pt = t.transform(0, 0)                 # IX 系の原点を WGS84 に座標変換
pt_inv = t_inv.transform(pt[0], pt[1]) # その点を IX 系に戻す。元に戻るか?

print "IX origin is #{pt.inspect} in WGS84\n"
print "#{pt_inv.inspect} must be (0, 0)\n"