9
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Kivyの座標変換はややこしい

Last updated at Posted at 2018-05-08

はじめに

例えば以下のようなコードがあったとします。

from kivy.app import runTouchApp
from kivy.lang import Builder


root = Builder.load_string(r'''
FloatLayout:
    RelativeLayout:
        size_hint: 0.8, 0.8,
        pos_hint: {'center_x': 0.5, 'center_y': 0.5, }
        GridLayout:
            pos_hint: {'x': 0, 'y': 0, }
            cols: 2
            Button:
                id: button1
                text: '1'
            Widget:
                id: trigger
            Widget:
            Button:
                id: button2
                text: '2'
    RelativeLayout:
        size_hint: 0.8, 0.8,
        pos_hint: {'right': 1, 'top': 1, }
        id: renderer
''')

runTouchApp(root)

coordinate_system.00.png

Widget階層が少し複雑なのでWidgetの境界を可視化させた時の結果も貼っておきます。
coordinate_system.01.png

ここでbutton1の中央からbutton2の中央へ線を引く処理をrendererに行わせたい時、どのようなコードを書きますか?もし素直に以下のように書いてしまうと

    RelativeLayout:
        size_hint: 0.8, 0.8,
        pos_hint: {'right': 1, 'top': 1, }
        id: renderer
        canvas:
            Line:
                points: [*button1.center, *button2.center, ]

期待する結果
coordinate_system.04.png

実際の結果
coordinate_system.02.png

となり、失敗してしまいます。

またtriggeron_touch_downイベントが起きた時に、Windowの左下からマウスカーソルの位置へ線を引く処理をrendererに行わせたい時、どのようなコードを書きますか?これも素直に以下のように書いてしまうと

#:import Line kivy.graphics.Line

            Widget:
                id: trigger
                on_touch_down: renderer.canvas.add(Line(points=[0, 0, *args[1].pos, ]))

期待する結果
coordinate_system.05.png

実際の結果
coordinate_system.03.png

と失敗してしまいます。

これらは全て、得た座標をそのままLinepointsに渡しているのが原因で、正しい結果を得るためにはちゃんと座標変換を行う必要があります。この記事では上の問題を解くと共にKivyの座標の仕組みについての私の理解を述べたいと思います。

ちょっとした逃げ道

その前にですが実は上のコードは、RelativeLayoutを全てFloatLayoutに置き換える事で期待する結果を得られます。 Widget階層の中にRelativeLayout系のWidgetが一つもない時は全ての座標が同じ座標系で表されているため、一切の変換の必要が無くなるのです。 RelativeLayout系のWidgetを全く使わないというのはとても大きな制約ではありますが、座標変換が分からない時の逃げ道として役に立つかもしれません。

語彙

最初に幾つか本記事で使う言葉の定義します。

祖先

以下のようなWidget階層がある時、本記事では

coordinate_system.ancestors.png

  • rootとBはCの祖先
  • rootはAの祖先
  • rootはBの祖先

と表現します。公式ドキュメントではparent stackに相当する言葉です。

RelativeLayout系のWidget

RelativeLayoutの子の位置指定は、RelatveLayoutの位置から相対的になります。これはRelativeLayoutが座標の原点を変えているからです。このように座標系を弄るWidgetは他にもScatter,ScrollViewがあり、公式ではそれらを纏めてRelativeLayout type widgetsと言っています。本記事ではこれをRelativeLayout系のWidget或いはRelative系のWidgetと言うことにします。

Window座標系

Windowの左下を原点とする座標系で、特に座標系を弄るWidgetが無い時の既定の座標系です。公式ではwindow coordinates

Local座標系

公式ではlocal coordinates
Local座標系はWindow座標系とは違い、基準とするWidgetにより変わります。私は初めLocal座標系を各Widgetの左下を原点とする座標系なのだと思っていました。なにせ名前にlocalと入っているわけですから。ところが次のコードを見てください。

from kivy.app import runTouchApp
from kivy.lang import Builder

root = Builder.load_string(r'''
FloatLayout:
    FloatLayout:
        id: hoge
        pos: 100, 100
        on_touch_down: print(self.to_widget(80, 80))
''')

runTouchApp(root)

to_widget()はWindow座標をLocal座標に変換するMethodです。この場合hogeto_widget()を呼んでいるので、Window座標(80,80)をhogeのLocal座標に変換しています。私の予想する出力は(-20,-20)でした。hogeの位置はWindow座標(100,100)で、Window座標(80,80)はそこから(-20,-20)の位置にあるからです。しかし実際は(80,80)でした。どうやらLocal座標系は私の思っている概念とは違うようです。

色々調べた結果KivyにおけるLocal座標系は以下のように決まるという結論に至りました。

  • 自身が非Relative系のWidgetの時、自身のLocal座標系は親のLocal座標系に等しい。
  • 自身がRelativeLayoutの時、自身の位置がLocal座標系の原点になる。 (話を単純にするためにRelativeLayoutに限定していますが、他のRelative系のWidgetでも考え方は同じです。)

例えば以下のWidget階層がある時

FloatLayout:
    id: _1
    RelativeLayout:
        id: _2
        pos: 10, 10
        FloatLayout:
            id: _3
            pos: 1, 1
            FloatLayout:
                id: _4
                pos: 1, 1
                RelativeLayout:
                    id: _5
                    pos: 30, 30
                    Widget:
                        id: _6
                        pos: 1, 1

各WidgetのLocal座標系の原点は

Widgetのid Relative系か? 自身の位置をWindow座標系で表すと Local座標系の原点の位置をWindow座標系で表すと
_1 No 0, 0 0, 0
_2 Yes 10, 10 10, 10
_3 No 11, 11 10, 10
_4 No 11, 11 10, 10
_5 Yes 40, 40 40, 40
_6 No 41, 41 40, 40

となります。

親の座標系

親のLocal座標系の事を略して親の座標系或いは親座標系と言うことにします。(公式ではparent coordinates)。何故この言葉を用意したかというと、一番よく使うからです。例えば

  • 普段何気なく使っている Widgetの座標を表すプロパティ(pos, x, y, center, center_x, center_y, top, right)は全て持ち主の親のLocal座標系です
  • また on_touch_xxx系のイベントで得られる座標もbindしたWidgetの親のLocal座標系です。
  • collide_point()へ渡す引数もMethodの持ち主の親のLocal座標系です。

on_touch_xxx系で得た座標をそのままcollide_point()へ渡せるのはこの為でもあります。両方共同じ親のLocal座標系なため、何の座標変換もいらないのです。

冒頭の問題を解く

Local座標系が分かったので冒頭で述べた問題を解いていこうと思います。

再掲

from kivy.app import runTouchApp
from kivy.lang import Builder


root = Builder.load_string(r'''
FloatLayout:
    RelativeLayout:
        size_hint: 0.8, 0.8,
        pos_hint: {'center_x': 0.5, 'center_y': 0.5, }
        GridLayout:
            pos_hint: {'x': 0, 'y': 0, }
            cols: 2
            Button:
                id: button1
                text: '1'
            Widget:
                id: trigger
            Widget:
            Button:
                id: button2
                text: '2'
    RelativeLayout:
        size_hint: 0.8, 0.8,
        pos_hint: {'right': 1, 'top': 1, }
        id: renderer
''')

runTouchApp(root)

coordinate_system.00.png

一つ目の問題

button1の中央からbutton2の中央へ線を引く処理をrendererに行わせたい時、どのようなコードを書きますか?

ここで考えないといけないのはbutton1.centerbutton2.centerが何の座標系で表されていて、rendererのcanvas内では何の座標系を使わないといけないのかです。前者はもう既に分かっています。親座標系の説明で書いたとおり、位置を表すプロパティは親座標系であるため、この場合はそれぞれbutton1の親座標系,button2の親座標系という事になります。(因みにbutton1button2は親が同じなのでbutton1の親座標系button2の親座標系は同じ物です)。後者なんですが、これは**rendererのLocal座標系**になります。canvasが何の座標系になっているかは基本的に

  • canvas.beforecanvasはLocal座標系
  • canvas.afterは親座標系

で、これに当てはまらない物は私は今の所ScrollView(もちろん派生Classも含む)しか知りません。

to_xxx()系Method

それぞれが何の座標系なのか分かったので、後は変換するだけです。Kivyは変換を行うためのMethodとして以下の4つを提供してくれているので順番に見ていきます。

to_window()

既定では親座標をWindow座標に変換し、引数initialにFalseを渡した時だけLocal座標をWindow座標に変換します。例えば何かのWidgethogeがある時、hoge.to_window(0, 0)hogeの親座標(0, 0)Window座標 に変換した結果を返し、hoge.to_window(0, 0, initial=False)hogeのLocal座標(0, 0)Window座標 に変換した結果を返します。 実は公式の説明では

Transform local coordinates to window coordinates. See relativelayout for details on the coordinate systems.

と書いてあり、これは訳すと「Local座標をWindow座標に変換する。詳しくは...」になり、私の説明とは異なります。私はソースを読んだりTestコードを書いたりしてそうとしか思えなかったので上の様に書きましたが、気になる人は自分で確かめるのが良いと思います。
(追記 2019/08/19: 現在はdocは修正され、私の説明と同等の物になっています)

to_widget()

Window座標をLocal座標に変換します。例: hoge.to_widget(0, 0)Window座標(0,0)hogeのLocal座標 に変換した結果を返す。

to_parent()

Local座標を親座標に変換します。例: hoge.to_parent(0, 0)hogeのLocal座標(0,0)hogeの親座標 に変換した結果を返す。

to_local()

親座標をLocal座標に変換します。例: hoge.to_local(0, 0)hogeの親座標(0,0)hogeのLocal座標 に変換した結果を返す。

一つ目の問題の続き

変換系Methodが分かったところで問題の続きをやりたいと思います。やらなければいけないのは button1の親座標(button1.center)button2の親座標(button2.center)rendererのLocal座標 に変換することです。直接変換するのは無理なので

  • button1の親座標(button1.center) -> Window座標 -> rendererのLocal座標
  • button2の親座標(button2.center) -> Window座標 -> rendererのLocal座標

と一度Window座標を経由させます。

    RelativeLayout:
        size_hint: 0.8, 0.8,
        pos_hint: {'right': 1, 'top': 1, }
        id: renderer
        canvas:
            Line:
                points:
                    [
                    *self.to_widget(*button1.to_window(*button1.center)),
                    *self.to_widget(*button2.to_window(*button2.center)),
                    ]

結果
coordinate_system.04.png

二つ目の問題

triggeron_touch_downイベントが起きた時に、Windowの左下からマウスカーソルの位置へ線を引く処理をrendererに行わせたい時、どのようなコードを書きますか?

親座標系の説明で書いたとおりon_touch_xxx系のイベントで得られる座標は親座標系です。なのでこの問題では

  • Window座標(0, 0) -> rendererのLocal座標
  • triggerの親座標(touch.pos) -> Window座標 -> rendererのLocal座標

という変換をする事になります。

#:import Line kivy.graphics.Line

            Widget:
                id: trigger
                on_touch_down:
                    renderer.canvas.add(Line(points=[
                    *renderer.to_widget(0, 0),
                    *renderer.to_widget(*self.to_window(*args[1].pos)),
                    ]))

結果
coordinate_system.05.png

ちなみにKv言語を用いない場合は以下の様になります。

from kivy.graphics import Line

trigger = root.ids.trigger
renderer = root.ids.renderer

trigger.bind(on_touch_down=(
    lambda __, touch: renderer.canvas.add(
        Line(points=[
            *renderer.to_widget(0, 0),
            *renderer.to_widget(*trigger.to_window(*touch.pos)),
        ])
    )
))

canvas.afterを使う場合

上の例ではどちらもcanvasを使いましたが、代わりにcanvas.afterを使う場合は以下のようになります。

一つ目の問題
    RelativeLayout:
        size_hint: 0.8, 0.8,
        pos_hint: {'right': 1, 'top': 1, }
        id: renderer
        canvas.after:  # 相違点
            Line:
                points:
                    [
                    *self.parent.to_widget(*button1.to_window(*button1.center)),  # 相違点
                    *self.parent.to_widget(*button2.to_window(*button2.center)),  # 相違点
                    ]
二つ目の問題
from kivy.graphics import Line

trigger = root.ids.trigger
renderer = root.ids.renderer

trigger.bind(on_touch_down=(
    lambda __, touch: renderer.canvas.after.add(  # 相違点
        Line(points=[
            *renderer.parent.to_widget(0, 0),  # 相違点
            *renderer.parent.to_widget(*trigger.to_window(*touch.pos)),  # 相違点
        ])
    )
))

canvas.afterでは親座標系を使わないといけないのが紛らわしいです。

最後に

座標系は私が最も理解に時間を要したKivyの概念です。to_window()のドキュメントの罠さえ無ければ、もっと早く理解できていたと思いますが...。正直最も速く理解する方法はソースコードを読むことだと思います。というのも全部とても短くて分かりやすいコードだからです。以下にLinkを貼るので参考にどうぞ。

kivy/uix/widget.py

全てのWidgetの基本Classであるkivy.uix.widget.Widgetのコードです。非Relative系のWidgetはいっさい上の四つのMethodを上書きしていないので、全ての非Relative系のWidgetの実装でもあります。

kivy/uix/relativelayout.py

Relative系のWidgetはどれもto_parent()to_local()だけを上書きしています。ScatterScrollViewもやっている事の本質はRelativeLayoutと同じなので、RelativeLayoutのコードを読むだけで十分だと思います。

kivy/core/window/__init__py

Widget.to_window()Widget.to_widget()は内部で親の同名のMethodを呼び出していますが、その呼び出しの連鎖の行き着く先がこれです。

9
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?