個人開発ソフトがドイツ博物館の書籍に掲載されました

個人開発しているソフトがドイツ博物館の書籍「DM Studies 17 Digitising Piano Rolls」に掲載されました。

www.deutsches-museum.de

この本はピアノロールのデジタル化の歴史・手法について170頁に渡り詳細に記されています。私の取り組みは以下4項目で言及されていました。

  • 動画撮影によるロールの読取
  • リプロデューシングロールのエミュレーション
  • 手動での演奏の解釈
  • AIによる演奏の解釈

後半にはPeter Phillips氏やWayne Stahnke氏などの業界の第一人者による学術的な執筆も豊富で、非常に勉強になる一冊です。

著者の方とは3年前から様々なやり取りを重ね今回の掲載に至りました。

PDF版は無料でダウンロード可能で製本版は35ユーロとの事です。折角なので記念に製本版が欲しくなります。

Python yfinanceとAWS Lambdaで株価取得のAPIを作る

指定した株価になるとLINEに通知するGASを作成しているのですが、株価を手軽に取得できるAPIが無いため、株情報を無料で取得できるPythonのyfinanceとAWS LambdaでAPIを自作します。

Python yfinanceでの株価取得

yfinance単体での株価取得例です。ここでは株価・PBR・PER・配当利回りを取得しています。

import yfinance as yf

stock = yf.Ticker("285A.T")  # 日本株は末尾に.Tを追加
price = stock.info.get("currentPrice")  # 現在価格
pbr = stock.info.get("priceToBook")  # PBR
per = stock.info.get("trailingPE")  # PER
div = stock.info.get("dividendYield")  # 配当利回り

これをベースにAWS LambdaでAPI化します。

AWS LambdaでAPI化

以下の手順でAPIを作成します。

  • 関数URLを有効にしてLambdaを作成
  • yfinanceのパッケージをLambdaに適用
    • CloudShellでyfinanceのPythonパッケージをzip化
    • Amazon S3にパッケージzipをアップロード→Lambdaに適用
Lambdaを作成

Python3.13のランタイムでLambdaを作成します。

外部から呼び出すために関数 URL を有効にします。認証タイプはNoneを指定。

サンプルコードと共に関数が作成されました。

yfinanceで株情報を返すコードを作成します。株番号はURLのクエリパラメータで渡します。

>lambda_function.py

import json
import yfinance as yf

def lambda_handler(event, context):
    try:
        stock_no = event.get("queryStringParameters", {}).get("stock_no")
        stock = yf.Ticker(stock_no)
        return {
            "statusCode": 200,
            "body": json.dumps({
                "Price": stock.info.get("currentPrice"),  # 現在価格,
                "PBR": stock.info.get("priceToBook"),  # PBR
                "PER": stock.info.get("trailingPE"),  # PER
                "Rimawari": stock.info.get("dividendYield")  # 配当利回り
            })
        }
    except Exception as e:
        raise Exception(str(e))

CloudShellでパッケージをzip化

Lambdaではpip installできないため、別環境で関連パッケージ類を抽出しzip化してアップロードする必要があります。

今回は適用先のLambdaと同じアーキテクチャのCloudShellで行います。

まず、Lambdaの環境と同じpython3.13をCloudShellにインストールします。

sudo yum install python3.13 python3.13-pip -y

次にpackagesディレクトリを作成してyfinanceのパッケージを入れます。

mkdir packages
pip3.13 install -t packages yfinance

packages/の中身は以下の様になります。pandasやnumpyにも依存してるとは…。

次にアクション→ファイルのアップロードより先ほど作成したlambda_function.pyをアップロードします。

~ $ ll
-rw-r--r--.  1 cloudshell-user cloudshell-user  649 Apr  5 05:39 lambda_function.py
drwxr-xr-x. 49 cloudshell-user cloudshell-user 4096 Apr  5 05:31 packages

zipに固めます。

cd packages/
zip -r ../packages.zip .

cd ..
zip packages.zip lambda_function.py 

出来上がったpackages.zipはアクション→ファイルのダウンロードよりDLします。

参考:出来上がったzipの構成。lambda_function.pyとyfinanceの関連パッケージが入っています。

S3にパッケージのzipをアップロード

zip容量が10MB未満ならLambdaで直接アップロード可能ですが今回は53MBと大きいためS3経由でアップロードする必要があります。

アップロードしたS3 URIをコピーしておきます。なお、後述のLambdaへのアップロード後はオブジェクトは不要になるので削除して大丈夫です。

Lambdaにパッケージのzipをアップロード

作成したLambdaのアップロード元→Amazon S3の場所より、パッケージzipのS3 URIを指定します。

保存を押して完了です。先ほど作成したS3のオブジェクトは削除してOKです。

APIエンドポイントのクエリパラメータに銘柄コードを指定して動作確認します。

MU (マイクロン)

>curl https://XXXXXXXXXXXXX.lambda-url.ap-northeast-1.on.aws/?stock_no=MU
{"Price": 366.24, "PBR": 5.701387, "PER": 17.267326, "Rimawari": 0.16}

3048.T (ビックカメラ)

>curl https://XXXXXXXXXXXXX.lambda-url.ap-northeast-1.on.aws/?stock_no=3048.T
{"Price": 1785.5, "PBR": 1.7940378, "PER": 17.51177, "Rimawari": 2.33}

正しく動作していますね。これで完成です。

2025年振り返り

気づけば2025年終わりですね。最近仕事が忙しすぎて年の瀬感が全くありません。

個人開発

今年も細々と個人開発を続け、既存ソフトの改良や新規ソフトのリリース、雑誌掲載などがありました。

GitHub - nai-kon/PlaySK-Piano-Roll-Reader: Convert Piano Roll Image to MIDI in real-time

メインで開発しているソフトで、今年は要望の多かったAmpico Aのロールに対応しました。Ampico Bに比べ制御パラメタが複雑に絡み合うため調査など含めて実装に苦労しましたが、結果的に満足の行くエミュレーションとなりました。

これで大半のフォーマットは対応済となり更新は落ち着きつつありますが、マイナーなロールへの対応要望がまだあるため牛歩で対応中です。

また、ドイツの業界紙にソフトの紹介記事が載りました。著者の方とは約2年前からやり取りをしており出版を楽しみにしていました。雑誌掲載は初めてで良い経験になりました。個人開発ソフトがドイツの雑誌に掲載されました - nai-konの開発備忘録

この他にも掲載が予定されているため、掲載されたら記事にしたいと思います。

GitHub - nai-kon/PlaySK-Midi-PianoRoll-Image-Converter: Convert Midi file to Piano Roll Image for PlaySK Piano Roll Reader

こちらはアメリカのユーザーからの依頼で7月に新規開発したソフトで、MIDIを忠実なピアノロール画像に変換するものです。通常のMIDIの変換はもちろん、e-roll MIDIと呼ばれるピアノロールの制御孔をそのままMIDI化したファイルを変換すれば元のロールに忠実な画像を生成できます。

変換した画像は最初に紹介したエミュレーションソフトで再生できます。e-roll MIDIはライブラリが豊富なため再生できる曲の幅が広がりました。以下はe-roll MIDIを画像化して再生したドホナーニの自作自演の演奏です。

youtu.be現状は変換画像の出力のみですが、将来的にはG-Code形式のファイルを出力してCNCのレーザーカッターで実物の紙を削孔できる様にしたいと思っています。

個人開発の収益については昨年と同じ位の額でした。お陰様でインフラ費用はペイできています。来年も収益で経費をカバーできればと思います。

LLM

ChatGPTの登場から早3年ですが、文章生成、画像・動画生成、コーディングなど公私共に使い倒した1年でした。特に今年は画像・動画生成の勢いを感じる1年で、動画はSora、画像はNano Banana Proを多用しました。

コーディングではClineとGitHub Copilotを活用しており、0→1の立ち上げはCline、調整はCopilotを活用しています。今年の新規開発ソフトもClineで0→1の立ち上げを行い非常に効率化しました。

便利に活用していますが、「時間かけるならAIにやらせた方が早い」というマインドになり思考力や実装力の低下を懸念しています。効率化してますが、これで良いのだろうかという何とも言えない気持ちです。

 

さて来年は何だか大きな動きがある1年になりそうです。何より個人開発のタスクが山積みなので少しずつ消化せねば…。

 

個人開発ソフトがドイツの雑誌に掲載されました

個人開発しているソフトがドイツの業界雑誌「Das Mechanische Musikinstrument」に掲載されました。

www.musica-mechanica.de

このソフトは他にはないユニークなソフトで、アメリカを中心に世界各国にユーザーがいます。雑誌の編集者から約2年前にコンタクトがあり、ソフトウェアの仕様や動作原理など様々なやり取りを重ね今回の掲載に至りました。当初は私自身が執筆する提案でしたが最終的にはプロの方に執筆をお願いしました。

個人開発ソフトの雑誌掲載は初めてで、かねてからの目標であったため達成感を覚えます。

雑誌の編集者から許可を頂いたので該当ページを掲載します。全てドイツ語ですが雰囲気は分かるかと思います。

 

さて、12月には別の雑誌の掲載も予定されています。掲載され次第ご報告します。

画像分類アプリのVLMをBLIP2→Qwen3-VLに更新

約3年前からXのタイムライン画像をVLMで分類するWebアプリを運用中です。雑多なタイムラインの画像を自分の好きな分類でフィルタリングできるので確認が捗ります。

以下は「食べ物」「猫」「犬」「その他」で分類する例です。

SalesforceのBLIP-2を用いて入力画像に含まれる物体をVQAしています。上の例では3件の質問をそれぞれVQAしています。
* Do you see any foods in the picture?
* Do you see any cats in the picture?
* Do you see any dogs in the picture?

1回のpromptで3件まとめてVQAしたい所ですが、精度が出なかったため1prompt=1件としています。それをbatch_size=3で推論しています。

特に問題なく運用していましたが、ここ最近OCRやSegmentationができる高性能なVLMが続々登場しており、この機会にモデルを更新することに。

Qwen3-VLを試す

Qwen3-VLは10月にリリースされた最新のVLMです。VQAに加えOCRタスクやBBOX座標出力ができる優れモノで、モデルサイズは2B~235BA22Bまで幅広くあります。

huggingface.co

今回は4B, 8B, 30BA3BのGGUFモデルを選びました。モデルはllama-serverでホストし、画像はbase64エンコードしてAPIに投げます。

llama-serverの起動

.\llama-server.exe -m Qwen3-VL-8B-Instruct-Q4_K_M.gguf --mmproj mmproj-Qwen3-VL-8B-Instruct-F16.gguf

VQAの実行例

img = Image.open("cat and dog.jpg")
img = resize_img(img, longest_edge=512)  # 長辺を指定pxでリサイズ

buffered = io.BytesIO()
img.save(buffered, format="BMP")
base64_image = base64.b64encode(buffered.getvalue()).decode('ascii')

prompt = """Answer following questions with only Yes/No. Separate by space.

* Do you see any foods?
* Do you see any cats?
* Do you see any dogs?
"""

messages = [{
    "role": "user",
    "content": [
        {
            "type": "image_url",
            "image_url": {
                    "url": f"data:image/bmp;base64,{base64_image}"
            },
        },
        {"type": "text", "text": prompt},
    ],
}]

model = OpenAI(base_url="http://localhost:8080", api_key="dummy-api-key")
completion = model.chat.completions.create(
    model="dummy-model",
    messages=messages,
    temperature=0.0,
)
print(completion.choices[0].message.content)  
# No Yes Yes

BLIP-2とは違い1promptで複数の質問を入れても精度が出ました。応答は「No Yes Yes」のように空白区切りで羅列して出力トークン数を減らします。

速度・精度

BLIP-2とQwen3の各モデルで精度・速度を比較します。

  • 特定の物体が含まれる正例画像50枚と、無関係の負例画像50枚を用意
  • 画像中に4つの分類が含まれるかで精度検証。分類の内訳は非公開
  • 検証マシンのスペックはCore i5 12600KF、RTX5060Ti 16GB、Win10 64bit
  • BLIP-2はhuggingfaceのtransformersで読込、Qwen3-VLはllama-serverで読込。
モデル 画像長辺px 正解率
正例50枚200項目
正解率
負例50枚200項目
推論時間 秒/枚
BLIP-2-flan-t5-xl FP16 - 53.5% 100.0% 0.17
         
Qwen3-VL-4B-Instruct Q4_K_M 256 84.5% 100.0% 0.10
  384 80.0% 100.0% 0.17
  512 83.0% 99.5% 0.21
  768 82.0% 100.0% 0.33
  1024 81.5% 100.0% 0.52
         
Qwen3-VL-8B-Instruct Q4_K_M 256 87.0% 99.5% 0.19
  384 89.5% 99.5% 0.22
  512 92.5% 100.0% 0.28
  768 93.0% 100.0% 0.48
  1024 90.5% 100.0% 0.84
         
Qwen3-VL-30B-A3B-Instruct Q4_K_M 256 91.0% 99.5% 1.40
※VRAMに乗り切らず遅 384 90.0% 100.0% 2.02
  512 91.0% 99.5% 2.79
  768 92.5% 100.0% 3.79
  1024 92.5% 100.0% 6.25

今回初めて定量評価しましたがBLIP-2が想定より低精度でした。実運用でも分類漏れは多少ありましたが数字で見ると中々です。

Qwen3-VLは、モデルサイズと精度が概ね比例し画像長辺サイズは512-768pxが最適となりました。正例と負例の精度に乖離がありますが、今回の分類には「物体の状態」の考慮が必要な分類があり、難易度が高くなっています。人間でも95%程が限界かもしれません。

 

実験結果より8B+長辺512pxを採用することにしました。早速アプリに組み込んで試していますが従来のBLIP-2に比べ圧倒的な精度向上を実感しています。もう少し早めに更新すれば良かったかも。

ゲストWiFiでSwitchBotのセグメントを分離

3年前からSwitchBotでエアコンや照明をスマホ操作しています。この時期はタイマーで暖房をセットして帰宅前には部屋が暖まる様にしており生活の必需品です。

普段使いのPCやスマホと同じネットワークにぶら下げてますが、中国製IoT機器を同一ネットワークで運用するのは危険との話もあるのでセグメントを分離することに。Swithbotの操作は自宅内からでも外部サーバー経由なのでネットワークを分離しても問題ありません。

セグメントの分離は当初はVLAN対応スイッチを検討しましたが使用中のJ:COMのモデムにゲストWiFi機能があるので代用します。

構成図

まず、モデムの管理画面からゲストAPのSSIDを新規作成して保存。

隔離モードは「すべて隔離」↓に設定します。

あとはSwithBotを新しいAPに接続して本体を再起動。

再設定後の接続端末一覧です。SwithBotだけ192.168.22の別セグメントに分離されています。

念のためAP間で通信が通らない事を確認して完了です。これで一安心ですね。

Appleのnotarytoolで「A required agreement is missing or has expired」のエラー

2か月ぶりにアプリを更新しようとGitHub Actionsでビルドを実行するとMac版アプリが公証でエラーになりました。

以下がエラー内容で、Apple Developer IDの契約が未同意か期限切れと言われています。

Error: HTTP status code: 403. A required agreement is missing or has expired. This request requires an in-effect agreement that has not been signed or has expired. Ensure your team has signed the necessary legal agreements and that they are not expired.

This process stores your credentials securely in the Keychain. You reference these credentials later using a profile name.

Validating your credentials...

今年4月にApple Developer IDの契約を更新したばかりで不思議に思いAppleにログインすると、いつの間にか使用許諾が更新され未同意の状態でした。


同意をして再度実行するとビルドが無事通りました。必要な時にCIが動かないと困るのでまめな確認が必要ですね。

さて、作成しているアプリに関する記事が今年の冬にドイツの業界誌 Das Mechanische Musikinstrument に掲載される事になりました。初めての雑誌掲載のため今から楽しみです。

llama-server上のgpt-ossのreasoning_effortを変更する

OpenAIのローカルLLM「gpt-oss」はOpenAI o1, o3と同様に思考レベル「reasoning_effort」を"low", "medium", "high"の3段階に動的変更できます。

システムプロンプトで以下の様に記述されます。デフォルトは"medium"です。

<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI.
Knowledge cutoff: 2024-06
Current date: 2025-08-07

Reasoning: medium

# Valid channels: analysis, commentary, final. Channel must be included for every message.<|end|>
openai-pythonライブラリからreasoning_effortを設定

llamacppのllama-serverでgpt-ossをホストしてopenai-pythonライブラリからreasoning_effortを設定します。

1. llama-serverを起動

llama-server -m gpt-oss-20b-mxfp4.gguf -c 0 -fa --jinja --reasoning-format none

localhost:8080でOpenAI互換のAPIサーバーが起動します。

2. openai-pythonライブラリ経由でリクエス

from openai import OpenAI

model = OpenAI(base_url="http://localhost:8080", api_key="dummy-api-key")
completion = model.chat.completions.create(
    model="dummy-model",
    messages=[{"role": "user", "content": "9.11と9.9の大小を比較してください"}],
    extra_body={"chat_template_kwargs":{"reasoning_effort": "low"}},
    # reasoning_effort="low",  # これは無視される
)

print(completion.choices[0].message.content)

reasoning_effortはextra_body={"chat_template_kwargs":{"reasoning_effort": "high"}}のように指定します。

オリジナルのreasoning_effort引数に指定しても反映されないので注意。

各reasoningレベルの出力は以下です。

reasoning_effort: low

<|channel|>analysis<|message|>Need to compare 9.11 and 9.9. 9.9 > 9.11? Actually 9.9 is 9.90; 9.11 is 9.11. So 9.9 > 9.11.<|start|>assistant<|channel|>final<|message|>

9.9 は 9.11 より大きいです。
(9.9 は 9.90 とみなせ、9.11 は 9.11 ですので、9.90 > 9.11)

reasoning_effort: medium

<|channel|>analysis<|message|>User: "9.11と9.9の大小を比較してください" Means: Compare the sizes of 9.11 and 9.9. They likely refer to numeric values: 9.11 vs 9.9. So 9.9 is larger than 9.11. If we interpret as decimal numbers, 9.9 > 9.11. The difference is 0.79. 9.9 is about 8.7% greater. Provide explanation: compare integer parts: both 9, compare decimal parts: .9 > .11. So answer: 9.9 > 9.11.

Also could mention difference. So answer: 9.9 is larger. Provide details.<|start|>assistant<|channel|>final<|message|>

**9.9 は 9.11 より大きい**  

- まず整数部を見ます:両方とも「9」なので整数部は等しい。  
- 次に小数部を比較します:  
  - 9.11 の小数部は「.11」  
  - 9.9 の小数部は「.9」  

0.9 は 0.11 より大きいので、9.9 は 9.11 より大きいです。  
差は \(9.9 - 9.11 = 0.79\) です。

reasoning_effort: high

<|channel|>analysis<|message|>We need to compare 9.11 and 9.9. The user presumably is Japanese: "9.11と9.9の大小を比較してください" means "Compare the magnitudes of 9.11 and 9.9." So presumably they want to know which is larger: 9.11 vs 9.9. Obviously 9.9 is larger. But we might also explain decimal values. 9.11 is 9.11, 9.9 is 9.9 = 9.90. So 9.90 > 9.11. So answer: 9.9 > 9.11. Also can mention difference: 0.79. So 9.9 - 9.11 = 0.79. So 9.9 is greater. Provide explanation. Probably they want just simple answer. Provide in Japanese.

So answer: 9.9 > 9.11. The difference is 0.79. Or 9.9 is 9.90, which is 0.79 greater. So answer in Japanese.

Thus: 9.9 は 9.11 より大きい。 9.9 - 9.11 = 0.79. That is the comparison. Also we can talk about decimal places. But it's enough.

We can also mention that 9.11 is 9 + 0.11, while 9.9 is 9 + 0.90. So obviously 0.90 > 0.11. So 9.9 > 9.11.

Hence answer: 9.9 is larger. Provide that. Also can mention that 9.11 < 9.9.

Thus answer in Japanese. Let's produce concise answer.<|start|>assistant<|channel|>final<|message|>

9.11(9 + 0.11)と9.9(9 + 0.90)を比較すると、0.90 の方が 0.11 より大きいので 

**9.9 > 9.11** です。差は 9.9 − 9.11 = 0.79 になります。

設定に応じてreasoning量が変化しています。いずれの回答も正しいです。

※なお、私は9.9と9.11はバージョン番号に見えるタチなので9.11が大きいと判断してしまいます…。

MIDIをリアルなピアノロール画像にするソフトを作成

MIDIからピアノロール画像を生成したい」という依頼がアメリカのユーザーからあり、ソフトをリリースしました。普段、ピアノロール画像をMIDIに変換するエミュレーターを開発していますが、それとは真逆なソフトです。

GitHub - nai-kon/PlaySK-Midi-PianoRoll-Image-Converter: Convert Midi file to Piano Roll Image for PlaySK Piano Roll Reader

MIDIファイルから音とそのタイミング(tick)を読み込み、設定したテンポやDPIから穴の描画位置・長さを計算してピアノロールの画像を生成します。

MIDIをピアノロール形式で表示するソフトは多数ありますが、このソフトはピアノロールの紙幅やパンチ穴のサイズ、ミシン目の様なパンチ穴など非常に忠実な見た目で画像を生成します。

変換した画像はピアノロールリーダーで再生したり、外部ソフトで画像→G-Codeに変換してレーザーカッターで本物のピアノロールを作成したりできます。画像をただ眺めても面白いです。

(変換の様子)

youtu.be

変換したサンプル画像 (クリックで全体表示)

主な機能
  • 通常のMIDIや、強弱等の制御孔をMIDI化したe-roll MIDIから画像を生成
    • 初版はマルチトラックや曲の途中でのテンポ変更には非対応
  • ロールのテンポ、画像のDPI、穴のサイズ・間隔、オフセットなどを設定可
  • ロールの加速補償に対応
    • ピアノロールは巻取速度が徐々に加速するため、加速の補償が必要
    • Stanford大学の論文より1フィート毎に0.2%加速補償するように設定 (調整可)
  •  88-Noteのトラッカーバーに対応
    • 今後、Ampico・Duo-Artなど拡大予定 
    • 現時点でもAmpico・Duo-Artのe-roll MIDIを変換できるが、穴のサイズ・オフセットは考慮されない
  • ダークモード・ライトモードを切替可
    • 個人的にダークモードは必須
コアロジック

コアロジックであるMIDIのtickを画像のピクセルに変換する計算式は以下です。

 ピクセル = tick * 出力DPI * ロールテンポ * 1.2 / BPM * PPQ

ピアノロールのテンポは"80"なら8フィート/分を意味します。ここでは1.2倍してインチに変換します。

これにロールの加速補償(0.2%/feet)を加えると以下になります。

ピクセル = ピクセル * (1.002 ^ (ピクセル / 出力DPI / 12))

開発言語・環境
  • Python、CustomTkinter、Pillow、mido
    • UIはモダンな見た目で人気のCustomTkinterを採用
    • 巨大画像を表示できるスクロールビュー等の独自widgetを作成
    • 画像はPillow、MIDIはmidoのライブラリを使用
  • GitHubGitHub Actions、uv
    • ビルド・リリースを自動化
    • パッケージ管理はuvを使用
  • Cloudflare Workers + D1

当初はインストール不要で手軽に扱えるようWebアプリにする予定でしたが、画像高が数十万pxと巨大でブラウザで扱うには難があり、またインフラ費用の懸念もあるためデスクトップアプリになりました。結局、Python+CustomTkinterで書いてます。最初は勉強がてら普段書かないJavaで書いていましたが、画像描画周りに致命的な欠陥があったり描画速度がPythonより遅かったりで採用を取り止めました。

 

初版をリリースして3日程ですが、早速海外のユーザーから好評や寄付がありました。新しいロールフォーマットの対応など今後も継続してアップデート予定です。

【CustomTkinter】CTkScrollableFrameで不要な時にスクロールバーを非表示にする

CTkScrollableFrameはコンテントが収まる場合も常にスクロールバーが表示されます。少し不格好。

はみ出る場合だけスクロールバーを表示したい所ですが、その様な設定は無いため自作します。

(対応後の動作)

対応内容

以下の様なCTkScrollableFrameをラップした子クラスを作成します。

class MyScrollableFrame(ctk.CTkScrollableFrame):
    def __init__(self, master, **kwargs):
        super().__init__(master, **kwargs)
        self._parent_canvas.configure(yscrollcommand=lambda top, bottom: self._auto_hide_scrollbar(top, bottom))

    def _auto_hide_scrollbar(self, top, bottom):
        if float(top) == 0 and float(bottom) == 1.0:
            self._scrollbar.grid_remove()
        else:
            self._scrollbar.grid()

        self._scrollbar.set(top, bottom)

基本的には元処理をそのまま流用し、yscrollcommandの処理だけ変更します。

なお、親クラスのyscrollcommandの処理は以下です。

self._parent_canvas.configure(yscrollcommand=self._scrollbar.set)

変更した処理のtop, bottomにはスクロールバーの上・下の位置が0.0-1.0で格納されており、例えばスクロールバーが中央にありスクロールバーのサイズがフレームの半分ならtop=0.25, bottom=0.75となります。

つまりコンテントがはみ出ずスクロールが不要な場合top=0, bottom=1となるため、その場合はgrid_remove()してスクロールバーを非表示にします。スクロールが必要な場合はgrid()で再表示します。

なお、非表示の関数にgrid_forget()がありますが、forgetの場合は再表示のgrid(...)に最初のgrid時と同じ引数指定が必要なため、同じ条件で表示・非表示を切り替える場合はgrid_remove()が推奨されます。

今回は縦スクロールのみ対応しましたが、横スクロールにも対応する場合はxscrollcommandに同様の処理を追加します。