この記事では・・・
1次元信号からUIを使って所望の範囲を指定、データを抜き出す機能の実装について書きます。MATLABで探した範囲ではそのような機能が見つけられなかったので実装しました。
環境
MATLAB R2023b
求める機能に近いもの
drawRectangle という関数があります。この関数は描画している画像の中から関心領域をインタラクティブに設定するのに使います。
figure; imshow(imread('baby.jpg'));
r1 = drawrectangle;
でカーソルが変わるので、ドラッグアンドドロップで四角形を描画できます。描画した四角形は、マウスで移動させたり、大きさを変えたりできるのでROIをインタラクティブに決めることができます。
これの1次元信号用が欲しい。
一次元信号なので区間(時間の範囲?)を指定できればいいため、X方向のみ操作できればいいのですが、drawRectangle使うと、Yにも動いてしまうので嬉しくありません。
他にぴったりの機能は無いのか・・・!
実は R2023a から xregion という、描画しているグラフに時間範囲を追加で書く関数が出ています。この関数では開始の時間と終了の時間を入力してあげるとその範囲を塗りつぶした下記のようなプロットを作ることができます。
これジャン!なのですが、この範囲はインタラクティブには動かせない様子。プロパティを見ても動かせそうな気配はありません。なんということでしょう…。
実装の方針
ということで、重い腰をあげて自作することにしました。方針としてはdrawRectangleを最大限利用します。
- 四角形の上端と下端はグラフ外にして見えないようにしたい。
- X方向にのみ動くようにしたい
- X方向にのみ伸び縮みさせたい(1の要件を満たせばおのずと解決)
- 描画だけでなくデータを抜き出したい
以上がdrawRectangleとの差分の要件です。
実装
適当なグラフを作って、実験しながら開発します。
fig = figure;
x = [0:100];
y = sin(2*pi*0.01*x);
p = plot(x,y);
四角形の上と下は見えないように
まず、1の実装ですが、描画するrectangleの上端と下端をグラフ外にしたいので、今描画しているY軸の値域を取得し、その前後一割くらいにしておけばイイかと思います。
ax = p.Parent; % 軸のオブジェクトを取得
ylim = ax.YLim;
ax_height = diff(ylim);
h = ax_height*1.2;
y1 = ylim(1) - ax_height*0.1;
Yについてはこの位置で決めたいので、関数を実行した瞬間にデフォルトの四角形を描画してしまう必要があります。
初期位置は Position プロパティで指定できるので、デフォルトのX座標についても同じように適当に決めます。
xlim = ax.XLim;
ax_width = diff(xlim);
w = ax_width*0.2;
x1 = xlim(1) + ax_width*0.1;
描画します。
r = drawrectangle("Position",[x1 y1 w h]);
狙い通りです。
X方向にのみ動くようにしたい
このままだとY方向にも動いてしまいます。やってみましょう。
ん・・・?あれ、Y方向に動かない・・・!
どうやら描画した四角の領域がグラフ外に出ないようにデフォルトで動きが制限されているらしいです。この場合、Y軸に関しては両側ともすでにグラフ外に出てしまっているので、Y軸方向には動かない、ということなんでしょうか…。嬉しい誤算です!
ということで特に何もしなくてもデフォルトでX軸方向にのみ動かせて、X軸方向にのみ伸び縮みするようにできました。
一連の流れは drawXrange という関数にしておきます。
描画だけでなくデータを抜き出したい
UIを作る
設定し終わった段階でのROIをベースにしてデータを抜き出したいのですが、いつ設定が終わったかは、明示的に知らせてあげないといけません。どっちみちマウス操作でROIを動かすので、横にボタンでも設置しておけば、データ抽出のタイミングをコントロールできそうです。
ボタンのようなUIを作るのは App Designer を使うのが簡単です。
すでに描画しているグラフはコピーする前提で、UIを作ってみます。
こんな感じでUIを作ってしまいました。ただappのままにしておくと起動がもたつきそうなので、UIを構築する部分のコードだけを抜き出して使うことにします。
下記の通りです。大元のapp構造体は、今は必要ないので、app.UIFigure -> fig, app.GridLayout -> g, app.UIAxes -> uiax, app.Button -> bという風に変数名は置換しました。また、タイトルなどの不要な箇所は削っています。
% Create UIFigure and hide until all components are created
fig = uifigure('Visible', 'off');
fig.Position = ax.Parent.Position;
fig.Name = 'ドラッグしてデータ抽出範囲を指定してください';
% Create GridLayout
g = uigridlayout(fig);
g.ColumnWidth = {'1x', '1x', '1x'};
g.RowHeight = {'0.1x', '1x'};
% Create UIAxes
uiax = uiaxes(g);
uiax.Layout.Row = 2;
uiax.Layout.Column = [1 3];
% Create Button
b = uibutton(g, 'push');
b.Layout.Row = 1;
b.Layout.Column = 2;
b.Text = 'データを抽出';
さらに、座標軸として表示したいのは既に存在している(範囲抽出したい)プロットなのでこの座標軸を丸々コピーして使うようにします。
uiax は次のように書き換えます。
uiax = copyobj(ax,g); % was uiax = uiaxes(g);
これを本体にして、ax を引数にとって作業ウィンドウ fig を作成する関数 create_uifig ができます。
ボタンを押してデータを抽出したい
ボタンを押した時に実行されるコールバック関数を定義します。
- ROIのX軸情報を取得
- プロットされているXとYのデータを取得
- XのデータとROI情報から抽出するデータのインデックスを決定
- インデックスでYからデータを抽出
という手順になります。ROIやプロットのデータには、グラフィックスオブジェクトからアクセスします。
function buttonPushedFcn(src,r)
% src: コールバックを呼びだしたソースオブジェクト(この場合はボタン)
% r: rectangleのオブジェクト
plot_obj = r.Parent.Children(2);
% ROIのX軸情報を取得
roi_x = r.Position([1 3]);
% プロットからXとYのデータを取得
xdata = plot_obj.XData;
ydata = plot_obj.YData;
% ROI内のデータのインデックス
idx = find(xdata>roi_x(1) & xdata<sum(roi_x));
% データの抽出
xdata = xdata(idx);
ydata = ydata(idx);
end
最終形態
ということで全体のコードは次のようになります。
function [xdata,ydata] = extract_data_from_range(ax)
% 作業ウィンドウを出す
[fig,g,uiax,b] = create_uifig(ax);
% 範囲を描画
r = drawXrange(uiax);
% ボタンを押した時のコールバックを設定
b.ButtonPushedFcn = @(src,event)buttonPushedFcn(src,r);
% ボタンが押されるまで待つ
waitfor(b,'UserData');
% 確認のために元のプロットに、抽出したプロットを重ね合わせて表示
fig_overlay = figure;
ax_overlay = copyobj(ax,fig_overlay);
hold(ax_overlay,'on');
plot(xdata,ydata,'Color','r','LineWidth',5);
hold(ax_overlay,'off');
function buttonPushedFcn(src,r)
plot_obj = r.Parent.Children(2);
% ROIのX軸情報を取得
roi_x = r.Position([1 3]);
% プロットからXとYのデータを取得
xdata = plot_obj.XData;
ydata = plot_obj.YData;
% ROI内のデータのインデックス
idx = find(xdata>roi_x(1) & xdata<sum(roi_x));
% データの抽出
xdata = xdata(idx);
ydata = ydata(idx);
src.UserData = 1;
end
end
function r = drawXrange(ax)
ylim = ax.YLim;
ax_height = diff(ylim);
h = ax_height*1.2;
y1 = ylim(1) - ax_height*0.1;
xlim = ax.XLim;
ax_width = diff(xlim);
w = ax_width*0.2;
x1 = xlim(1) + ax_width*0.1;
r = drawrectangle("Position",[x1 y1 w h]);
end
function [fig,g,uiax,b] = create_uifig(ax)
% Create UIFigure and hide until all components are created
fig = uifigure('Visible', 'off');
fig.Position = ax.Parent.Position;
fig.Name = 'ドラッグしてデータ抽出範囲を指定してください';
% Create GridLayout
g = uigridlayout(fig);
g.ColumnWidth = {'1x', '1x', '1x'};
g.RowHeight = {'0.1x', '1x'};
% Create UIAxes
uiax = copyobj(ax,g);
title(uiax, '')
uiax.Layout.Row = 2;
uiax.Layout.Column = [1 3];
% Create Button
b = uibutton(g, 'push');
b.Layout.Row = 1;
b.Layout.Column = 2;
b.Text = 'データを抽出';
% Show the figure after all components are created
fig.Visible = 'on';
end
2点ハマりポイントがありましたので、補足します。
ボタンがおされるまで実行を止める
コールバックを設定しただけでは、ボタンを押す前に関数が実行し終わってしまい、データ抽出まですることができません。
ですので、ボタンが押されるまで待つ、という処理が必要で、それが waitfor になります。
ここでは、ボタンのプロパティの一つである UserData を活用し、コールバックの最後に UserData に実行完了フラグとして 1 を詰める処理を追加しました。
waitfor は、この変化を検知して、関数の実行を再開します。この時点では xdata, ydata はできているため、関数の戻り値として機能します。
コールバックは戻り値を返せないので、入れ子関数に
ボタンを押した段階で処理されるコールバックですが、戻り値を取ることができません。
ですので、出力を関数の外に出すことができません。
回避策としてここではコールバックは入れ子関数として書きました。
入れ子関数の変数のスコープは呼び出し元と共有されるので全体の関数の戻り値にできます。
ということで次のようにテストするときちんと動きました。記事冒頭のgifです。満足です。
% データ作成
x = [0:100];
y = sin(2*pi*0.01*x);
% 描画
plot(x,y)
% オブジェクト取得
ax = gca;
% ツール起動
[x_,y_] = extract_data_from_range(ax);
まとめ
この記事では、1次元グラフプロットからUIで範囲を指定して、その範囲のデータを抜き出す関数を作成しました。
UIの作成は App Designer で楽して作りましょう。