Welcome to telecotele.com » 丁寧な暮らし

CVBSのデコード

オシロスコープで観測したNTSCの信号をPythonスクリプトでデコードします

tags: intercom

CVBSのデコードのカバー画像

オシロスコープ購入してやることといえば? そう、コンポジット映像信号のデコードですね。 ということでコンポジット映像信号、具体的にはNTSCの信号を観測しそれをデコードしてみます。

なお、デコードしてみようと思ったのは下記の記事がきっかけで、実装についてもこちらを大いに参考とさせてもらいました。

NTSCビデオ信号をデコードしたい #NTSC - Qiita

NTSCについて

NTSCの歴史は古く、広く普及していることから多くの情報が得られます。 詳細な情報は仕様書や書籍、また後述の参考にさせてもらったページを見てもらうとして、自分の理解のため調べた内容をまとめておくと……

  • RS-170
    • RSは、“RS-232C”などと同じRS
    • モノクロ映像を扱う
    • 電圧を基にした “IRE” という単位で輝度が表現される
    • 日本では 0[IRE](米国では7.5[IRE])を黒色とし、100[IRE]を白色とする
    • IREは負数になることもあり得る
      • 制御信号を伝えるため
    • 水平同期信号がある
      • -40[IRE]の信号が、4.7usほど出力される
      • この後に(多少の期間を挟み)1行分の輝度信号が続く
    • 垂直同期信号もある
      • 1フレーム内の全行が出力し終わり、次の新フレームに移行したことを伝える
    • 映像はインターレース
  • RS-170A (SMPTE 170M)
    • 一般的にこちらを “NTSC” と呼ぶ模様
    • RS-170を基にし、カラーに対応した規格
    • RS-170しか扱えない受信機でも(モノクロなら)見れるように互換性がある
    • 色差信号UVを変調して輝度信号に重畳
      • UVは、YUVのUV
      • 変調された色差信号は C (Chroma) と呼ばれる
      • 搬送波は 3.5795˙4˙3.579\dot5\dot4 MHz、変調はアナログQAM(!)
    • 水平同期信号のあと、「カラーバースト」が出力される
      • 受信機では、これと同期してCの復調に必要な情報を得る
      • もしカラーバーストが無いなら、モノクロの映像であることを示す
    • 受信機側ではYとCを分離すれば色を復元できる
      • Yはそのまま輝度、Cは復調すればUとVが手に入るので
      • この分離は「Y/C分離」と呼ばれる

です。この内容は下記ページの内容を参照させてもらいました。

elm-chan.org RS-170A NTSCビデオ信号タイミング規格の概要

PIC AVR 工作室 ビデオ表示のツボ

ケンケンのホームページ PICマイコンによるカラーコンポジットビデオ出力実験

The Composite TV Signal (EP 23) - YouTube

CVBS信号解析_cvbs信号波形-CSDN博客

なお、NTSCはコンポジット映像信号 (CVBS: Composite Video Baseband Signal) の一種です。 CVBSとは、色や輝度や制御信号が一つの信号に重畳されたもので……確かにそうですね。 CVBSとしては他にもPALやSECAMといった規格があるようですが、電圧で輝度を表すという点や水平同期信号の幅がすべて4.7usだったりとNTSCと似たところもあります(ただし解像度や色の表現方法は違う)。

CVBSの発展?として、AHDやHD-TVI、HDCVIといった規格もありました。 主に監視カメラ向けの映像伝送で使われており、やはり「電圧で輝度を表す」タイプです。 これらもいろいろコンポジットされていると思いますが、単なるCVBSとは分けられている模様。 少なくともAHDの波形としてはNTSCに似ている1、ただしカラーバーストの周波数は70MHzに達することもある2、水平同期信号の幅はぱっと見1usくらい(AHD, HD-TVI)または2usくらい(HDCVI)に見えます3。 今回はこの辺のデコードをするわけではないですが、いずれ触ることになるかも知れません。

NTSC信号のキャプチャ

NTSCをデコードするため、まず信号をキャプチャしましょう。

ところが、もはや自宅にはNTSCの信号を出す機器があったかは微妙です。 ESP32などを使って信号を自分で作る?それは正しい信号を出しているのか分からなくなりそうなのでなし。 実家やリサイクルショップに行けばビデオデッキかPS2か何かあるのでは? あとは夢グループの「テレビ麻雀ゲーム」を買う? う~ん、ちょっとためらってしまいます。 いろいろ探したら、iPhoneのDockコネクタからCVBSを引き出す基板を見つけました。 いや、DockコネクタのiPhone自体もう無い!困った。

最終的にRaspberry PiがCVBS出力を備えていたことを思い出し、使っていなかったRaspberry Pi Zero WでなんとかNTSCの信号を出力しました。(このテレビは10年近く使っていて初めてCVBSに繋いだ。後述しますがY/C分離がうまい、これが当然と思ってはいけない)

Raspberry PiによるCVBS出力 Raspberry PiによるCVBS出力

ちなみにこのときの解像度は下記のとおりです。

pi@raspberrypi:~ $ tvservice -s
state 0x40001 [NTSC 4:3], 720x480 @ 60.00Hz, interlaced

この信号をオシロスコープで確認します。 あるラインに対してトリガーを設定し観測した結果は次のとおりです。

NTSCの波形 NTSCの波形

(横取りで見たわけではなく、75Ωの終端抵抗も一切挟まず見ましたが)それっぽいですね。 よく分からない?いやいやそれっぽいんですよ。 水平同期信号とカラーバーストがあって、その後画素の信号が続いている様子が見えます。 コンソールの画面を見るより、たとえばカラーバーを表示したりすると分かりやすかったかも知れませんが……その辺は横着しました。

これをメモリ長の許す限り、そしてNTSCにおける色信号の搬送波である約3.58MHzの10倍以上(50Msps)でサンプリングした様子はこうです。

メモリ長の限りサンプリングした結果 メモリ長の限りサンプリングした結果

この画像は自慢のために載せました。 メモリ長が長い4のが最高すぎる。 1秒分、60フレーム分くらい取れました(こんなに要らない)。 CSVにすると1.5GB分あります。 モノクロで良ければ、またサンプリングレートの限界ギリギリを攻めればもっと長期間取れるかも知れません(要らない)。

モノクロ画像のデコード

では取得したデータをデコードしましょう。 まずはモノクロ画像としてデコードしてみます。

実はこのデータは「フィールドIのライン1をトリガー」としており、トリガー後のデータを順次読んでいけば特に水平同期や垂直同期を意識する必要はありません。 ただ、将来的に「NTSCっぽい」(本当にそうかは謎、CVBSの一種やAHDなどと似てはいそう)と言われ、さらにトリガーを設定できるかも分からない信号をデコードする可能性があり、ここでは水平同期や垂直同期もあえて考慮してデコードします。 とはいえ製品を開発するわけではないので真剣に考えるつもりもありません。

そこで、こんな方針でデコードすることにしました。

  1. 水平同期信号を見つける
    • -30[IRE]を下回る信号が、だいたい4.7usくらい続いていたら水平同期信号とする
  2. 垂直同期信号を見つける
    • 1.で検出した水平同期信号の間隔がいつもより長いところは「垂直同期信号があった」とみなす
    • (垂直同期信号にはインターレース解除のため偶数or奇数フレームか示す信号もあるが無視)
  3. それぞれの水平同期信号に続く電圧値をIREを経て輝度に変換。垂直同期信号が来たら新たな画像の開始とみなす

これにならって-40[IRE]が黒、100[IRE]を白としてあるフレームをデコード、さらに適当にリサイズするとこうなります(一部加工)。

RS-170に基づくデコード RS-170に基づくデコード

いい感じですね。でもなんか文字が読みにくいような……? それもそのはず、ここでは1フレーム分見ただけでインターレースを解除していません。 ちゃんと2フレーム見て、インターレースを解除するとこうなります(一部加工)。

RS-170に基づくデコード(インターレース解除版) RS-170に基づくデコード(インターレース解除版)

なんかシマシマっぽい。また、Raspberry Piのロゴを見ると目がチカチカしますがこんなもんでしょう。 ちなみに、実際の受信機で同期パルスを処理する回路については下記のペスト医師による動画が詳しいです。

The Composite TV Signal, Part 2! Vertical Blanking! (EP 45) - YouTube

How I Designed a Simple Video Sync Separator (EP 47) - YouTube

カラー画像のデコード

続いて色も復元してみましょう。

そのためにはまずY/C分離から始めます。 ただ、これは難しい話です。 そもそもCVBSではなくS端子とかD端子とか使えば(YにCが重畳されていないので)Y/C分離の話からは逃げられます。 でもソースがアナログ放送だった時代では逃げられなかったことでしょう。 現代はデジタル放送になって幸せですね。 MPEG2のノイズがどうこうとか、マルチチャネル放送や降雨対応放送で低画質になったとかとはまた違った難しさがあります。 画面右上に出る「アナログ」、テロップの位置が4:3基準、けいおん!の4:3版……うっ頭が……。

話をY/C分離に戻し、Cの搬送波はYに比べて高いです。 それならLPFとかBPFとか使えば良いんですよ。 ……と思ってしまいますが、これだと完璧には分離できないようです(うまくいく場合もあります)。 ではどうするかというと、色搬送波の位相が行ごとに180℃反転することに着目し一つ前の行(遅延させた行)と現在の行との和を計算すれば色信号が相殺され2で割ればYとなる、一方で差は色信号が増強され残るのでこれも2で割ってCとしたりします(2Dコムフィルタ)。 もちろんこれがうまくいくのは上の行と現在の行が「同じ輝度 かつ 同じ色」(もっと言えばノイズも一切無い)という場合であり、状況によっては下の行のほうが似ていたらそちらに切り替えたり、上下だけではなく周囲数行も見ることもあるようです。 また、現在のフレームだけでなく過去のフレームも見る(3Dコムフィルタ)ことで精度を上げたりしています5

大変ですね。 現代ではNTSCデコーダとして3Dコムフィルタを実装したICも販売されています。 やったぜ。でも今回はそうしたICは使えません。なんてこった……。

冒頭でも示した下記の記事によれば、Y/C分離は基本的な2Dコムフィルタで行っているようです。

NTSCビデオ信号をデコードしたい #NTSC - Qiita

こちらを参考に、またアナログQAMの復調(これも大変難しい……)も大いに参考にさせてもらいカラー画像をデコードするとこうなります(0[IRE]を黒とした、リサイズ済み、一部加工)。

RS-170Aに基づくデコード(2Dコムフィルタ版) RS-170Aに基づくデコード(2Dコムフィルタ版)

FIRなどは使わず素朴に実装した結果です。 現代では飛行機の機内モニタでしか見ないような、懐かしい感じの画質ですね(この画像は令和6年に作成しました)。 薄目で見ればいい感じですが……拡大すると特にエッジ部分でY/C分離に失敗している様子が見えます。

文字が多い画像に対して基本的な2Dコムフィルタは不適切だったかも知れません。 一応BPF/LPF版も示します。

RS-170Aに基づくデコード(BPF,LPF版) RS-170Aに基づくデコード(BPF,LPF版)

拡大してみると、この画像についてはこっちのほうが良いんじゃないでしょうか? とはいえ「NTSC信号のキャプチャ」で示したテレビの画面と比べるとうまくY/C分離できていないのは明らかで、さすがそっちは家庭用でも製品として売られているだけあります。

おわりに

コンポジット映像信号として、NTSCのデコードを試しました。 なんとかデコードできて良かった。

なお、今回デコードに利用したPythonスクリプトは下記のとおりです。

長いので折りたたみ
import numpy as np
from scipy import signal
from PIL import Image
import os

# モノクロ
# インターレースは考えない
def rs170_simple(h_syncs, v_syncs, voltage, skip_frame=0):
    for n in range(skip_frame, v_syncs.size-1):
        frame_h_syncs = h_syncs[v_syncs[n]+1:v_syncs[n+1]]
        width = np.max(np.diff(frame_h_syncs))
        height = frame_h_syncs.size
        print(f"rs170_simple frame:{n}, width:{width}, height:{height}")
        img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
        pixels = img.load()
        for y in range(height-1):
            data = voltage[frame_h_syncs[y]:frame_h_syncs[y+1]]
            for x in range(data.size):
                ire = ((data[x] - H_SYNC_IRE_V) / IRE_UNIT_V) - 40
                rgb = int((ire + 40) * 1.82142857143)
                if rgb < 0:   rgb = 0
                if rgb > 255: rgb = 255
                pixels[x, y] = (rgb, rgb, rgb, 255)
        img.save(f'rs170_simple_frame{n}.png')
        img.resize((int(width/4), height*2)).save(f'rs170_simple_frame{n}_resize.png')

# モノクロ・インターレース解除版
# 0.5本の走査線は何も考えない
def rs170(h_syncs, v_syncs, voltage, skip_frame=0):
    for n in range(skip_frame, v_syncs.size-2, 2):
        frame1_h_syncs = h_syncs[v_syncs[n]+1:v_syncs[n+1]]
        frame2_h_syncs = h_syncs[v_syncs[n+1]+1:v_syncs[n+2]]
        width = max(np.max(np.diff(frame1_h_syncs)), np.max(np.diff(frame2_h_syncs)))
        height = max(frame1_h_syncs.size, frame2_h_syncs.size)*2 + 1
        print(f"rs170 frame:{n}, width:{width}, height:{height}")
        img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
        pixels = img.load()
        # Frame 1
        for y in range(frame1_h_syncs.size-1):
            data = voltage[frame1_h_syncs[y]:frame1_h_syncs[y+1]]
            for x in range(data.size):
                ire = ((data[x] - H_SYNC_IRE_V) / IRE_UNIT_V) - 40
                rgb = int((ire + 40) * 1.82142857143)
                if rgb < 0:   rgb = 0
                if rgb > 255: rgb = 255
                pixels[x, y*2] = (rgb, rgb, rgb, 255)
        # Frame 2
        for y in range(frame2_h_syncs.size-1):
            data = voltage[frame2_h_syncs[y]:frame2_h_syncs[y+1]]
            for x in range(data.size):
                ire = ((data[x] - H_SYNC_IRE_V) / IRE_UNIT_V) - 40
                rgb = int((ire + 40) * 1.82142857143)
                if rgb < 0:   rgb = 0
                if rgb > 255: rgb = 255
                pixels[x, y*2+1] = (rgb, rgb, rgb, 255)
        img.save(f'rs170_frame{n}.png')
        img.resize((int(width/4), height)).save(f'rs170_frame{n}_resize.png')

# カラー
# https://qiita.com/amutou/items/98c0cd84a3691b0f545a
def rs170a(h_syncs, v_syncs, voltage, sample_rate, skip_frame=0):
    fsc = 3579545 # 副搬送波 約3.8MHz
    points_per_line = int(np.percentile(np.diff(h_syncs), 95))
    burst_start = int(sample_rate / fsc * 19)     # 水平同期の始まりから19サイクル後、カラーバースト開始
    burst_end = int(sample_rate / fsc * (19 + 9)) # 8~11サイクル続く。典型的には9サイクル?
    color_subcarrier_angle_speed = fsc / sample_rate * np.pi * 2
    phase = np.arange(points_per_line) * color_subcarrier_angle_speed

    for n in range(skip_frame, v_syncs.size-2, 2):
        frame1_h_syncs = h_syncs[v_syncs[n]+1:v_syncs[n+1]]
        frame2_h_syncs = h_syncs[v_syncs[n+1]+1:v_syncs[n+2]]
        width = points_per_line
        height = max(frame1_h_syncs.size, frame2_h_syncs.size)*2 + 1
        print(f"rs170a frame:{n}, width:{width}, height:{height}")
        img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
        pixels = img.load()
        # Frame 1
        for y in range(1, frame1_h_syncs.size-1):
            prev_ire = ((voltage[frame1_h_syncs[y-1]:frame1_h_syncs[y]] - H_SYNC_IRE_V) / IRE_UNIT_V) - 40
            data_ire = ((voltage[frame1_h_syncs[y]:frame1_h_syncs[y+1]] - H_SYNC_IRE_V) / IRE_UNIT_V) - 40
            if prev_ire.size != points_per_line: prev_ire = np.resize(prev_ire, points_per_line)
            if data_ire.size != points_per_line: data_ire = np.resize(data_ire, points_per_line)
            burst = data_ire[burst_start:burst_end]
            burst_phase = np.arange(burst_start, burst_end) * color_subcarrier_angle_speed
            re = np.sum(burst * np.cos(burst_phase))
            im = np.sum(burst * np.sin(burst_phase))
            rad = np.arctan2(re, im) + np.pi
            Y = (data_ire + prev_ire) / 2.0 / 100.0
            C = (data_ire - prev_ire) / 2.0 / 40.0
            U = C * np.sin(rad + phase)
            V = C * np.cos(rad + phase)
            R = np.clip(255 * (Y + V * 1.13983), 0, 255)
            G = np.clip(255 * (Y + U * -0.39465 + V * -0.5806), 0, 255)
            B = np.clip(255 * (Y + U * 2.03211), 0, 255)
            for x in range(data_ire.size): pixels[x, y*2] = (int(R[x]), int(G[x]), int(B[x]), 255)
        # Frame 2
        for y in range(1, frame2_h_syncs.size-1):
            prev_ire = ((voltage[frame2_h_syncs[y-1]:frame2_h_syncs[y]] - H_SYNC_IRE_V) / IRE_UNIT_V) - 40
            data_ire = ((voltage[frame2_h_syncs[y]:frame2_h_syncs[y+1]] - H_SYNC_IRE_V) / IRE_UNIT_V) - 40
            if prev_ire.size != points_per_line: prev_ire = np.resize(prev_ire, points_per_line)
            if data_ire.size != points_per_line: data_ire = np.resize(data_ire, points_per_line)
            burst = data_ire[burst_start:burst_end]
            burst_phase = np.arange(burst_start, burst_end) * color_subcarrier_angle_speed
            re = np.sum(burst * np.cos(burst_phase))
            im = np.sum(burst * np.sin(burst_phase))
            rad = np.arctan2(re, im) + np.pi
            Y = (data_ire + prev_ire) / 2.0 / 100.0
            C = (data_ire - prev_ire) / 2.0 / 40.0
            U = C * np.sin(rad + phase)
            V = C * np.cos(rad + phase)
            R = np.clip(255 * (Y + V * 1.13983), 0, 255)
            G = np.clip(255 * (Y + U * -0.39465 + V * -0.5806), 0, 255)
            B = np.clip(255 * (Y + U * 2.03211), 0, 255)
            for x in range(data_ire.size): pixels[x, y*2+1] = (int(R[x]), int(G[x]), int(B[x]), 255)
        img.save(f'rs170a_frame{n}.png')
        img.resize((int(width/4), height)).save(f'rs170a_frame{n}_resize.png')

# カラー
# rs170aを基にY/C分離をBPFでやる版
def rs170a_bpf(h_syncs, v_syncs, voltage, sample_rate, skip_frame=0):
    def bandpass_filter(data, fs, center_freq, bandwidth):
        nyq = 0.5 * fs
        low = (center_freq - bandwidth/2) / nyq
        high = (center_freq + bandwidth/2) / nyq
        b, a = signal.butter(6, [low, high], btype='band')
        return signal.filtfilt(b, a, data)

    def lowpass_filter(data, fs, cutoff_freq):
        nyq = 0.5 * fs
        normal_cutoff = cutoff_freq / nyq
        b, a = signal.butter(6, normal_cutoff, btype='low')
        return signal.filtfilt(b, a, data)

    fsc = 3579545 # 副搬送波 約3.8MHz
    bpf_bandwidth = 1.0e6
    lowpass_cutoff = 3.0e6

    points_per_line = int(np.percentile(np.diff(h_syncs), 95))
    burst_start = int(sample_rate / fsc * 19)     # 水平同期の始まりから19サイクル後、カラーバースト開始
    burst_end = int(sample_rate / fsc * (19 + 9)) # 8~11サイクル続く。典型的には9サイクル?
    color_subcarrier_angle_speed = fsc / sample_rate * np.pi * 2
    phase = np.arange(points_per_line) * color_subcarrier_angle_speed

    for n in range(skip_frame, v_syncs.size-2, 2):
        frame1_h_syncs = h_syncs[v_syncs[n]+1:v_syncs[n+1]]
        frame2_h_syncs = h_syncs[v_syncs[n+1]+1:v_syncs[n+2]]
        width = points_per_line
        height = max(frame1_h_syncs.size, frame2_h_syncs.size)*2 + 1
        print(f"rs170a_bpf frame:{n}, width:{width}, height:{height}")
        img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
        pixels = img.load()
        # Frame 1
        for y in range(1, frame1_h_syncs.size-1):
            data_ire = ((voltage[frame1_h_syncs[y]:frame1_h_syncs[y+1]] - H_SYNC_IRE_V) / IRE_UNIT_V) - 40
            if data_ire.size != points_per_line: data_ire = np.resize(data_ire, points_per_line)
            burst = data_ire[burst_start:burst_end]
            burst_phase = np.arange(burst_start, burst_end) * color_subcarrier_angle_speed
            re = np.sum(burst * np.cos(burst_phase))
            im = np.sum(burst * np.sin(burst_phase))
            rad = np.arctan2(re, im) + np.pi
            Y = lowpass_filter(data_ire, sample_rate, lowpass_cutoff) / 100.0
            C = bandpass_filter(data_ire, sample_rate, fsc, bpf_bandwidth) / 40.0
            U = C * np.sin(rad + phase)
            V = C * np.cos(rad + phase)
            R = np.clip(255 * (Y + V * 1.13983), 0, 255)
            G = np.clip(255 * (Y + U * -0.39465 + V * -0.5806), 0, 255)
            B = np.clip(255 * (Y + U * 2.03211), 0, 255)
            for x in range(data_ire.size): pixels[x, y*2] = (int(R[x]), int(G[x]), int(B[x]), 255)
        # Frame 2
        for y in range(1, frame2_h_syncs.size-1):
            data_ire = ((voltage[frame2_h_syncs[y]:frame2_h_syncs[y+1]] - H_SYNC_IRE_V) / IRE_UNIT_V) - 40
            if data_ire.size != points_per_line: data_ire = np.resize(data_ire, points_per_line)
            burst = data_ire[burst_start:burst_end]
            burst_phase = np.arange(burst_start, burst_end) * color_subcarrier_angle_speed
            re = np.sum(burst * np.cos(burst_phase))
            im = np.sum(burst * np.sin(burst_phase))
            rad = np.arctan2(re, im) + np.pi
            Y = lowpass_filter(data_ire, sample_rate, lowpass_cutoff) / 100.0
            C = bandpass_filter(data_ire, sample_rate, fsc, bpf_bandwidth) / 40.0
            U = C * np.sin(rad + phase)
            V = C * np.cos(rad + phase)
            R = np.clip(255 * (Y + V * 1.13983), 0, 255)
            G = np.clip(255 * (Y + U * -0.39465 + V * -0.5806), 0, 255)
            B = np.clip(255 * (Y + U * 2.03211), 0, 255)
            for x in range(data_ire.size): pixels[x, y*2+1] = (int(R[x]), int(G[x]), int(B[x]), 255)
        img.save(f'rs170a_frame{n}.png')
        img.resize((int(width/4), height)).save(f'rs170a_bpf_frame{n}_resize.png')

def load(csv_file, binary_file, refresh=False):
    if (not refresh) and os.path.exists(binary_file+'.npy'): return np.load(binary_file+'.npy')
    data = np.genfromtxt(csv_file, delimiter=',', skip_header=0)
    np.save(binary_file, data)
    return data

if __name__ == '__main__':
    data = load('data.csv', 'data.bin')
    timestamp = data[:, 0]
    voltage = data[:, 1]

    ZERO_IRE_V    = 0.845 # [V],   0 [IRE](事前に人間がデータを見てこのくらいなことを確認)
    H_SYNC_IRE_V  = 0.268 # [V], -40 [IRE]
    IRE_UNIT_V    = (ZERO_IRE_V - H_SYNC_IRE_V) / 40.0

    # 水平同期
    h_sync_threshold = ZERO_IRE_V - 30*IRE_UNIT_V
    h_sync_candidate = np.where(voltage < h_sync_threshold)[0]  # -30[IRE]より低く
    h_sync_breaks = np.where(np.diff(h_sync_candidate) > 10)[0] # インデックスが非連続な点の
    h_sync_starts = np.concatenate(([h_sync_candidate[0]], h_sync_candidate[h_sync_breaks + 1])) # 先頭たち
    h_sync_ends   = np.concatenate((h_sync_candidate[h_sync_breaks], [h_sync_candidate[-1]]))    # 末尾たち
    h_sync_durations = timestamp[h_sync_ends] - timestamp[h_sync_starts]
    # 先頭~末尾が4.7usっぽいなら水平同期とみなす
    h_syncs = h_sync_starts[np.where((h_sync_durations > 4.4e-6) & (h_sync_durations < 5.0e-6))[0]]

    # 垂直同期
    # 水平同期の間隔が長い、ならば垂直同期があったとみなす
    v_sync_threshold = np.percentile(np.diff(h_syncs), 95) * 10 # 水平同期10個分くらいの期間
    v_syncs = np.where(np.diff(h_syncs) > v_sync_threshold)[0]
    v_syncs = np.insert(v_syncs, 0, 0)
    v_syncs = np.append(v_syncs, h_syncs.size-1)

    # 画像書き出し
    SKIP_FRAME = 0 # 決め打ち。最初のフレームが偶数or奇数かはこれで制御
    rs170_simple(h_syncs, v_syncs, voltage, SKIP_FRAME)
    rs170(h_syncs, v_syncs, voltage, 0)

    SAMPLE_RATE = 1 / np.mean(np.diff(timestamp))
    rs170a(h_syncs, v_syncs, voltage, SAMPLE_RATE, SKIP_FRAME)
    rs170a_bpf(h_syncs, v_syncs, voltage, SAMPLE_RATE, SKIP_FRAME)

もっとNumpyを活用したり似たような処理を見直せば、よりシンプルに書けることでしょう。 おわり。

Footnotes

  1. 视频信号AHD信号解析_ahd信号波形-CSDN博客

  2. 日本防犯設備協会 同軸アナログHDシステムに関する調査研究報告書 P14

  3. P2 outputting hi def using AHD video camera format? — Parallax Forums

  4. 注釈で後置きにしますが「エントリーモデルにしては」という前置きつき。このオシロは演算結果に対してトリガーを設定できず至ってしまうところでしたがこのメモリ長があるなら生き返れます。演算結果に対するトリガーが可能なゾーントリガー付きのオシロだったらメモリ長ももっと長いと思いますが、そもそも予算的に手が出ません。

  5. HDディスプレイにSD映像を高画質で表示 - EDN Japan