アナログオシロスコープの画面を模してみる

ノスタルジック企画です。アナログオシロスコープの画面を WPF アプリケーションで模してみました。

大きな目盛りは XAML で静的に描画アイテムを定義しておきました。小さな目盛りは100個ちかくあるので全てXAMLで定義するわけにはいきません。コードから動的に生成しました。

下記の記事で紹介した、静的描画アイテム用のキャンバスと動的描画アイテム用のキャンバスを2個定義して、それを重ねるテクニックを使っています。

静的に描画アイテムを準備し、動的に描画アイテムを作成し画像の上に描画する

XAMLで準備しておいた静的な描画アイテムと、コードで動的に生成配置した描画アイテムを同時に使う場合のキャンバスの扱い方を紹介します。

下記の4画面は奇数次のサイン波合成した信号を入力した表示を模したものです。

サイン波合成なし
奇数次サイン波10回加算
奇数次サイン波100回加算
奇数次サイン波1000回加算

下記の4画面は、表示振幅を変化させた表示を模したものです。

振幅10倍
振幅50倍
振幅100倍
振幅200倍

下記の2画面はスケールイルミのツマミで目盛りの明暗調整をした表示を模したものです。

スケールイルミが暗い
スケールイルミが明るい

XAML で、静的な描画アイテム用のキャンバスと、動的に生成配置する描画アイテム用のキャンバスを二つ用意しておきます。

  • 78行目は静的な描画アイテム用の Canvas です。
  • 110行目は動的に生成配置する描画アイテム用の Canvas です。
<Window x:Class="aaa.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:aaa"
        mc:Ignorable="d"
        Title="MainWindow"  Width="720" Height="760">

    <DockPanel>

        <Menu DockPanel.Dock="Top">

            <MenuItem x:Name="menuApplication" Header="Application">
                <MenuItem x:Name="menuApplicationQuit" Header="Quit" Click="menuApplicationQuit_Click"/>
            </MenuItem>

            <MenuItem x:Name="menuLoop" Header="Loop">
                <MenuItem x:Name="menuLoop0001" Header=    "1" Click="menuLoopNnnn_Click"/>
                <MenuItem x:Name="menuLoop0010" Header=   "10" Click="menuLoopNnnn_Click"/>
                <MenuItem x:Name="menuLoop0100" Header=  "100" Click="menuLoopNnnn_Click"/>
                <MenuItem x:Name="menuLoop1000" Header= "1000" Click="menuLoopNnnn_Click"/>
            </MenuItem>

            <MenuItem x:Name="menuAmp" Header="Amp">
                <MenuItem x:Name="menuAmp010" Header= "10" Click="menuAmpNnn_Click"/>
                <MenuItem x:Name="menuAmp020" Header= "20" Click="menuAmpNnn_Click"/>
                <MenuItem x:Name="menuAmp030" Header= "30" Click="menuAmpNnn_Click"/>
                <MenuItem x:Name="menuAmp040" Header= "40" Click="menuAmpNnn_Click"/>
                <MenuItem x:Name="menuAmp050" Header= "50" Click="menuAmpNnn_Click"/>
                <MenuItem x:Name="menuAmp100" Header="100" Click="menuAmpNnn_Click"/>
                <MenuItem x:Name="menuAmp150" Header="150" Click="menuAmpNnn_Click"/>
                <MenuItem x:Name="menuAmp200" Header="200" Click="menuAmpNnn_Click"/>
            </MenuItem>

            <MenuItem x:Name="menuScaleIllum" Header="Scale Illum">
                <MenuItem x:Name="menuScaleIllumBright" Header= "Bright" Click="menuScaleIllumBright_Click"/>
                <MenuItem x:Name="menuScaleIllumDark" Header= "Dark" Click="menuScaleIllumDark_Click"/>
            </MenuItem>

        </Menu>

        <ToolBarTray DockPanel.Dock="Top">
            <ToolBar>
                <Button x:Name="tbnApplicationQuit" Content="Quit" Width ="48" Height="48" Click="menuApplicationQuit_Click"></Button>
                <Separator/>
                <Button x:Name="tbnLoop0001"  Content=   "1" Width ="48" Height="48" Click="menuLoopNnnn_Click"></Button>
                <Button x:Name="tbnLoop0010"  Content=  "10" Width ="48" Height="48" Click="menuLoopNnnn_Click"></Button>
                <Button x:Name="tbnLoop0100"  Content= "100" Width ="48" Height="48" Click="menuLoopNnnn_Click"></Button>
                <Button x:Name="tbnLoop1000"  Content="1000" Width ="48" Height="48" Click="menuLoopNnnn_Click"></Button>
                <Separator/>
                <Button x:Name="tbnAmp010" Content= "10" Width ="48" Height="48" Click="menuAmpNnn_Click"></Button>
                <Button x:Name="tbnAmp020" Content= "20" Width ="48" Height="48" Click="menuAmpNnn_Click"></Button>
                <Button x:Name="tbnAmp030" Content= "30" Width ="48" Height="48" Click="menuAmpNnn_Click"></Button>
                <Button x:Name="tbnAmp040" Content= "40" Width ="48" Height="48" Click="menuAmpNnn_Click"></Button>
                <Button x:Name="tbnAmp050" Content= "50" Width ="48" Height="48" Click="menuAmpNnn_Click"></Button>
                <Button x:Name="tbnAmp100" Content="100" Width ="48" Height="48" Click="menuAmpNnn_Click"></Button>
                <Button x:Name="tbnAmp150" Content="150" Width ="48" Height="48" Click="menuAmpNnn_Click"></Button>
                <Button x:Name="tbnAmp200" Content="200" Width ="48" Height="48" Click="menuAmpNnn_Click"></Button>
            </ToolBar>
        </ToolBarTray>

        <StatusBar DockPanel.Dock="Bottom" Height="30">
            <Label Name="stbLabel000"/>
            <Separator/>
            <Label Name="stbLabel001"/>
            <Separator/>
            <Label Name="stbLabel002"/>
        </StatusBar>

        <Border BorderBrush="Teal" BorderThickness="1" >
            <ScrollViewer Name="scv000" Background="Teal" ScrollViewer.HorizontalScrollBarVisibility="Visible" ScrollViewer.VerticalScrollBarVisibility="Visible">
                <Grid x:Name="grid000">

                    <!-- このイメージはダミーなので透明にする -->
                    <Image x:Name="image000" Opacity="0.0" Stretch="Uniform"/>

                    <Canvas x:Name="canvas_static" Background="Transparent">

                        <!-- 波形そのもの -->
                        <Polyline x:Name="poly" Stroke="Aquamarine" StrokeThickness="2" />

                        <!-- 大きいほうの水平目盛り -->
                        <Line x:Name="line_horz000" Stroke="DarkSlateGray" StrokeThickness="1" />
                        <Line x:Name="line_horz001" Stroke="DarkSlateGray" StrokeThickness="1" />
                        <Line x:Name="line_horz002" Stroke="DarkSlateGray" StrokeThickness="1" />
                        <Line x:Name="line_horz003" Stroke="DarkSlateGray" StrokeThickness="1" />
                        <Line x:Name="line_horz004" Stroke="DarkSlateGray" StrokeThickness="1" />
                        <Line x:Name="line_horz005" Stroke="DarkSlateGray" StrokeThickness="1" />
                        <Line x:Name="line_horz006" Stroke="DarkSlateGray" StrokeThickness="1" />
                        <Line x:Name="line_horz007" Stroke="DarkSlateGray" StrokeThickness="1" />

                        <!-- 大きいほうの垂直目盛り -->
                        <Line x:Name="line_vert000" Stroke="DarkSlateGray" StrokeThickness="1" />
                        <Line x:Name="line_vert001" Stroke="DarkSlateGray" StrokeThickness="1" />
                        <Line x:Name="line_vert002" Stroke="DarkSlateGray" StrokeThickness="1" />
                        <Line x:Name="line_vert003" Stroke="DarkSlateGray" StrokeThickness="1" />
                        <Line x:Name="line_vert004" Stroke="DarkSlateGray" StrokeThickness="1" />
                        <Line x:Name="line_vert005" Stroke="DarkSlateGray" StrokeThickness="1" />
                        <Line x:Name="line_vert006" Stroke="DarkSlateGray" StrokeThickness="1" />
                        <Line x:Name="line_vert007" Stroke="DarkSlateGray" StrokeThickness="1" />
                        <Line x:Name="line_vert008" Stroke="DarkSlateGray" StrokeThickness="1" />
                        <Line x:Name="line_vert009" Stroke="DarkSlateGray" StrokeThickness="1" />

                        <!-- 目盛りの外枠の矩形 -->
                        <Rectangle x:Name="rect000" Stroke="DarkSlateGray" StrokeThickness="1"/>

                    </Canvas>

                    <Canvas x:Name="canvas_dynamic" Background="Transparent">
                        <!-- 動的に動画アイテムを描画するほうのキャンバス -->
                    </Canvas>

                </Grid>
            </ScrollViewer>
        </Border>

    </DockPanel>


</Window>

次に MainWindow.xaml.cs の解説を続けます。

42行目は、コンピュータで数式を扱う場合の定番で、x と f(x) をワンセットで扱うための構造体 struct の定義です。ここはクラス class の定義にするほうが今どきなのかもしれませんが、有名な数式のライブラリなどを眺めてもクラスにしているケースは少ない気がします。

91~133行目はサイン波合成の関数です。サイン波の奇数次の高調波を足しこんでいくことで矩形波をつくります。

いろいろな次数のサイン波の加算を駆使することで、三角波、ノコギリ波、いろいろ生成できますので興味のある方はここをいじってみてください。

using System.Windows.Media.Imaging; の追加をお忘れなく.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Navigation;
using System.Windows.Shapes;
using System.Windows.Media;

// これの追加を忘れないように. Don't forget to add this sentence.
using System.Windows.Media.Imaging;

namespace aaa
{

	public partial class MainWindow : Window
	{

		// オシロスコープのOneDivisionのサイズ.
		const int ONE_DIV_W = 50;
		const int ONE_DIV_H = 50;

		// オシロスコープの大きいほうの目盛り分割数.
		const int NUM_W = 10;
		const int NUM_H =  8;

		// オシロスコープの細かいほうの目盛り分割数.
		const int TICKS = 5;

		// オシロスコープ表示レート初期値.
		double Amp = 100.0;

		// サイン波合成の加算回数.
		int SumLoop = 10;

		// 波形のイチ要素の独自定義型.
		struct GY_X_FX
		{
			public double x;
			public double fx;
		}

		// 波形の格納領域
		GY_X_FX [] Wave;

		// ダミーのビットマップ.
		WriteableBitmap Bmp000;

		// スケールイルミの明暗フラグ.
		bool FlagScaleIllumBright = false;

		////////////////////////////////////////////////////////////////
		////////////////////////////////////////////////////////////////
		////////////////////////////////////////////////////////////////

		public MainWindow()
		{

			// これはデフォルトのコード.
			InitializeComponent();

			// データ領域を確保する.
			int w = ONE_DIV_W * NUM_W;
			int h = ONE_DIV_W * NUM_H;

			// ビットマップを生成する.
			const double DPI_X = 96.0;
			const double DPI_Y = 96.0;
			PixelFormat pixfmt = PixelFormats.Bgra32;
			Bmp000 = new WriteableBitmap( w, h, DPI_X, DPI_Y, pixfmt, null );
			
			// 画像出力先のサイズを決定してビットマップを関連付ける.
			image000.Width = Bmp000.PixelWidth;
			image000.Height = Bmp000.PixelHeight;
			image000.Source = Bmp000;

			// 画像出力先はダミーなので透明にする.
			image000.Opacity = 0.0;

			// ウインドウのタイトル.
			this.Title = "GazoYaro";

		}

		// サイン波合成の波形を取得する.
		private bool get_wave( GY_X_FX [] data, int loop )
		{

			if ( data == null )
			{
				return false;
			}

			if ( data.Length <= 0 )
			{
				return false;
			}

			if ( loop <= 0 )
			{
				return false;
			}

			int num_data = data.Length;

			for ( int n = 0; n < num_data; n++ )
			{

				double rate = (double)(n)/(double)( num_data - 1 );
				double rad = rate * 2.0 * Math.PI;

				double sum = 0.0;

				for ( int dn = 0; dn < loop; dn++ )
				{
					int x = (( dn * 2 ) + 1 );
					double mul = (double)( 1.0 )/(double)(x);
					sum += ( mul * Math.Sin( rad * x ));
				}

				data[n].x = rad;
				data[n].fx = sum;

			}

			return true;

		}

		protected override void OnRender( DrawingContext dc )
		{

			// 画像のサイズを取得する.
			int w = Bmp000.PixelWidth;
			int h = Bmp000.PixelHeight;

			// 親要素 Grid000 の座標系から image000 のオフセット量を取得する.
			Point img_point = image000.TranslatePoint( new Point( 0, 0 ), grid000 );
			double plot_offset_x = img_point.X;
			double plot_offset_y = img_point.Y;

			// 表示する波形を取得する.
			Wave = new GY_X_FX[ w ];
			get_wave( Wave, SumLoop );

			// オシロスコープの真ん中の水平線Y座標.
			double center_y = ( h * 0.5 );

			// 波形を記録する座標コレクション.
			PointCollection pnts = new PointCollection();

			for ( int i = 0; i < w; i++ )
			{

				// 振幅をかけて描画座標とする.
				double tmp = Wave[i].fx * Amp;

				// 座標コレクションにデータを追加する.
				double x = plot_offset_x + i;
				double y = plot_offset_y + center_y - tmp;
				pnts.Add( new Point( x, y ));

			}

			// 波形を示す座標コレクションをポリラインに関連付ける.
			poly.Points = pnts;

			// 目盛り線や外枠線の色.
			Brush c_frame;

			if ( FlagScaleIllumBright )
			{
				// スケールイルミを明るく.
				c_frame = Brushes.Brown;
			}
			else
			{
				// スケールイルミを暗く.
				c_frame = Brushes.DarkSlateGray;
			}

			List<Line> list_line_vert = new List<Line>();
			list_line_vert.Add( line_vert000 );
			list_line_vert.Add( line_vert001 );
			list_line_vert.Add( line_vert002 );
			list_line_vert.Add( line_vert003 );
			list_line_vert.Add( line_vert004 );
			list_line_vert.Add( line_vert005 );
			list_line_vert.Add( line_vert006 );
			list_line_vert.Add( line_vert007 );
			list_line_vert.Add( line_vert008 );
			list_line_vert.Add( line_vert009 );

			List<Line> list_line_horz = new List<Line>();
			list_line_horz.Add( line_horz000 );
			list_line_horz.Add( line_horz001 );
			list_line_horz.Add( line_horz002 );
			list_line_horz.Add( line_horz003 );
			list_line_horz.Add( line_horz004 );
			list_line_horz.Add( line_horz005 );
			list_line_horz.Add( line_horz006 );
			list_line_horz.Add( line_horz007 );

			// 大きい目盛りの垂直線を配置する.
			for ( int i = 0; i < NUM_W; i++ )
			{
				list_line_vert[i].Stroke = c_frame;

				list_line_vert[i].X1 = plot_offset_x + ( ONE_DIV_W * i );
				list_line_vert[i].Y1 = plot_offset_y + 0;

				list_line_vert[i].X2 = list_line_vert[i].X1;
				list_line_vert[i].Y2 = plot_offset_y + h;
			}

			// 大きい目盛りの水平線を配置する.
			for ( int j = 0; j < NUM_H; j++ )
			{
				list_line_horz[j].Stroke = c_frame;

				list_line_horz[j].X1 = plot_offset_x + 0;
				list_line_horz[j].Y1 = plot_offset_y + ( ONE_DIV_W * j );

				list_line_horz[j].X2 = plot_offset_x + w;
				list_line_horz[j].Y2 = list_line_horz[j].Y1;
			}

			// 外枠を配置する.
			rect000.Width = w;
			rect000.Height = h;
			rect000.Stroke = c_frame;
			Canvas.SetLeft( rect000, plot_offset_x );
			Canvas.SetTop( rect000, plot_offset_y );

			// ---------------------------------------------------
			// ここから, 動的に描画アイテムを生成して描画する.
			// ---------------------------------------------------

			// 細かい目盛りの長さを指定する. 6という数字は雰囲気で決めた.
			double tip_len = (double)( ONE_DIV_H )/(double)( 6 );

			// 細かい目盛りの色と太さは、大きい目盛りと同じにする.
			Brush stroke_color = list_line_vert[0].Stroke;
			double stroke_thickness = list_line_vert[0].StrokeThickness;

			// 描画アイテムを全クリアする.
			canvas_dynamic.Children.Clear();

			// 細かい目盛りの垂直線.
			for ( int i = 0; i < NUM_W; i++ )
			{

				double one_pitch = (double)( ONE_DIV_W )/(double)( TICKS );

				for ( int di = 0; di < TICKS; di++ )
				{

					Line line_tick = new Line();
					line_tick.Stroke = stroke_color;
					line_tick.StrokeThickness = stroke_thickness;

					line_tick.X1 = plot_offset_x + ( ONE_DIV_W * i ) + ( one_pitch * di );
					line_tick.Y1 = plot_offset_y + center_y - ( tip_len * 0.5 );

					line_tick.X2 = line_tick.X1;
					line_tick.Y2 = plot_offset_y + center_y + ( tip_len * 0.5 );

					// キャンバスに描画アイテムを追加する.
					canvas_dynamic.Children.Add( line_tick );

				}

			}

			// 細かい目盛りの水平線.
			for ( int j = 0; j < NUM_H; j++ )
			{

				double center_x = ( w * 0.5 );
				double one_pitch = (double)( ONE_DIV_H )/(double)( TICKS );

				for ( int dj = 0; dj < TICKS; dj++ )
				{

					Line line_tick = new Line();
					line_tick.Stroke = stroke_color;
					line_tick.StrokeThickness = stroke_thickness;

					line_tick.X1 = plot_offset_x + center_x - ( tip_len * 0.5 );
					line_tick.Y1 = plot_offset_y + ( ONE_DIV_H * j ) + ( one_pitch * dj );

					line_tick.X2 = plot_offset_x + center_x + ( tip_len * 0.5 );
					line_tick.Y2 = line_tick.Y1;

					// キャンバスに描画アイテムを追加する.
					canvas_dynamic.Children.Add( line_tick );

				}

			}

			// ---------------------------------------------------
			// 動的に描画アイテムを生成して描画する, ここまで.
			// ---------------------------------------------------

			// ステータスバーのラベルに座標を表示する.
			String str000 = String.Format( "SumLoop {0}", SumLoop );
			String str001 = String.Format( "Amp {0:f1}", Amp );
			String str002 = "SCALE ILLUM";

			if ( FlagScaleIllumBright )
			{
				str002 += ":bright";
			}
			else
			{
				str002 += ":dark";
			}

			stbLabel000.Content = str000;
			stbLabel001.Content = str001;
			stbLabel002.Content = str002;

		}

		private void menuLoopNnnn_Click( object sender, RoutedEventArgs e )
		{

			if      ( sender.Equals( menuLoop0001 ) || sender.Equals( tbnLoop0001 ) ) { SumLoop =    1; }
			else if ( sender.Equals( menuLoop0010 ) || sender.Equals( tbnLoop0010 ) ) { SumLoop =   10; }
			else if ( sender.Equals( menuLoop0100 ) || sender.Equals( tbnLoop0100 ) ) { SumLoop =  100; }
			else if ( sender.Equals( menuLoop0100 ) || sender.Equals( tbnLoop1000 ) ) { SumLoop = 1000; }
			else                                                                      { return; }

			this.InvalidateVisual();

		}

		private void menuAmpNnn_Click( object sender, RoutedEventArgs e )
		{

			if      ( sender.Equals( menuAmp010 ) || sender.Equals( tbnAmp010 ) ) { Amp =  10.0; }
			else if ( sender.Equals( menuAmp020 ) || sender.Equals( tbnAmp020 ) ) { Amp =  20.0; }
			else if ( sender.Equals( menuAmp030 ) || sender.Equals( tbnAmp030 ) ) { Amp =  30.0; }
			else if ( sender.Equals( menuAmp040 ) || sender.Equals( tbnAmp040 ) ) { Amp =  40.0; }
			else if ( sender.Equals( menuAmp050 ) || sender.Equals( tbnAmp050 ) ) { Amp =  50.0; }
			else if ( sender.Equals( menuAmp100 ) || sender.Equals( tbnAmp100 ) ) { Amp = 100.0; }
			else if ( sender.Equals( menuAmp150 ) || sender.Equals( tbnAmp150 ) ) { Amp = 150.0; }
			else if ( sender.Equals( menuAmp200 ) || sender.Equals( tbnAmp200 ) ) { Amp = 200.0; }
			else                                                                  { return; }

			this.InvalidateVisual();

		}

		private void menuScaleIllumBright_Click( object sender, RoutedEventArgs e )
		{
			FlagScaleIllumBright = true;
			this.InvalidateVisual();
		}

		private void menuScaleIllumDark_Click( object sender, RoutedEventArgs e )
		{
			FlagScaleIllumBright = false;
			this.InvalidateVisual();
		}

		private void menuApplicationQuit_Click( object sender, RoutedEventArgs e )
		{
			// ウインドウを閉じてアプリケーションを終了する.
			this.Close();
		}

	}

}

われながら、うまい画面を作ったなと自画自賛であります。

うーむ、スケールイルミとか懐かしい。シングルショットの波形を撮影したら一瞬だけスケールイルミをいじって目盛りをフィルムに露光したり、いろいろ妙なテクニックが身に付きました。

大学を卒業して会社に入ったら GP-IB でオシロスコープ波形をパソコンに取り込んでいて、やはり企業は効率優先だなと思ったものです。

本記事で作った画面は、ちょっと昔の Tektronix とか IWATSU とか KENWOOD とかそういう感じです。もっと古い感じにするならば、背景色をグレーにすればいいでしょう。また、背景を真っ黒にして、目盛りや波形をオレンジ色にすれば、YOKOGAWA のプリンタ付きオシロの感じですかね。

サンプルソースを用意しました。ぜひみなさんの慣れ親しんだオシロスコープの雰囲気の再現にチャレンジしてみてください。