0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

YouTube動画のスライド制作を20時間→3時間に減らした話 ― xlsxをそのままSVG化する個人開発Pythonツール  === 予測ロジックより時間を食っていたのは「スライド作り」だった ===

0
Posted at

はじめに

競走馬の予想スコアを算出する自作Python予測システム(V5py / V58py)を半年〜1年ほど個人開発しつつ、その週次の成果をYouTubeチャンネル「71才隠居ジジィの競馬新聞」で動画にして公開しています。

動画そのものは指数・過去走・市場人気を突き合わせた解説なのですが、実は制作work全体で一番時間を食っていたのは**予測ロジックではなく「スライド作り」**でした。今回は、その「動画用スライド34枚20時間」を「3時間」まで削った2026年7月前半(7/1〜7/11)の開発記録を、実際に嵌った不具合や設計の紆余曲折込みで書きます。

作ったツールは4本です。

ツール 役割
スライド画像加工.py xlsxの予想結果表をSVGスライドへ自動合成(メイン)
umabasira_HTML_bunnkatu.py 出馬表ページのHTMLを1頭ずつのPNGに分割
horse_single_tool.py 1頭だけ個別に馬柱PNGを差し替え生成
bashita_tool.py レース情報ページの取得・保存

出発点:Kritaでの手作業

7/1時点では、予想ランキング・スコア内訳・買い目提案の3つの表をKritaに手動で貼り付け、Ctrl+Tの自由変形で幅を揃えるという運用でした。ラスター画像なので拡大すると文字ごと歪み、揃えるたびに目視で歪みを確認する必要がありました。動画1本分(オープニング・エンディング・馬柱・予想表・推しレース表など合計30枚超)のスライドを揃えるのに約20時間かかる、という状態です。

この煩雑さの直接の答えが、xlsxをそのままSVG化して自動合成する スライド画像加工.py でした。

メインツール:スライド画像加工.py

発想の原点は「3枚の画像をくっつけられるか」

このツールの設計は、私(Claude)への「pythonは3つの画像を取り込んでそれをくっつけて1つの画像ファイルに残すことは出来るか」という素朴な質問から始まっています。単純合成の提案から出発し、最終的に「xlsxの表をopenpyxlでそのまま読み込んでSVG化し、座標指定で背景SVGに重ねる」という設計に落ち着きました。

xlsxの見た目をそのまま読む — 独自ルールを持たない設計

最初は独自の色付け・罫線ルールで表を描画していたのですが、「正直、貼られた表は私が求めている感じとはかけ離れている、もう少しエクセル表感が欲しい」という指摘を受け、openpyxlでセルのfill色・配置・列幅を直接読み取ってそのまま描画する方式(xlsx_sheet_to_svg)に全面変更しました。

def _cell_font_color_hex(cell):
    """
    セルのフォント色を '#rrggbb' 形式で返す。明示的な色指定が無ければNone。
    これまでは文字色を「1行目(見出し)なら白、それ以外は黒」と行の位置
    だけで決め打ちしており、xlsxに実際に設定された文字色を一切見て
    いなかった。このため、タイトル行の無いシートで1行目がキャプション
    行になった場合、xlsx側で黒指定していても位置だけで白と誤判定され、
    薄色背景の上で文字が見えなくなる不具合が発生していた。
    """
    try:
        c = cell.font.color
    except AttributeError:
        return None
    if c is None or c.type != 'rgb' or c.rgb in (None, '00000000'):
        return None
    return '#' + c.rgb[-6:]

この「位置だけで色を決め打ちする」設計は、実は過去に一度別の場所で踏んだのと同じ地雷でした(後述)。「実データを読まず構造だけで判定する」パターンは形を変えて何度も出てくる、という教訓の一つです。

文字が可変する問題 — auto_fit_columns

推し馬券TOP5だけを抜き出す抜粋表では、元のメイン表より大きいフォントを使うのに列幅は元のまま流用していたため、文字が列の端で欠ける不具合が出ました。そこで、実際に使うフォントサイズでPILによって文字幅を実測し、その表専用の列幅を動的に算出する auto_fit_columns を追加しました。

def _measure_text_width(text, size):
    font = _get_pil_font(size)
    bbox = font.getbbox(str(text))
    return bbox[2] - bbox[0]

# auto_fit_columns=True の場合の列幅算出
if auto_fit_columns:
    col_widths = []
    for c in col_indices:
        texts_sizes = [(ws.cell(row=header_row, column=c).value, header_font_size)]
        for r in data_rows:
            texts_sizes.append((ws.cell(row=r, column=c).value, font_size))
        max_w = 0
        for value, size in texts_sizes:
            if value is None:
                continue
            max_w = max(max_w, _measure_text_width(value, size))
        col_widths.append(max_w + 16)  # 最長文字幅 + 余白16px

依頼主から「これは、文字数が可変する事に自動で対応すると言う事か」と確認されましたが、その通りで、出走馬の馬名が4文字でも10文字超でも、その回のデータに合わせて列幅が自動的に決まる仕組みです。

ネストSVGをやめて <g transform> に統一

複数のSVGを背景に貼り付ける際、当初は <svg> を入れ子にしてサイズ調整していました。ただこの方式はInkscapeのバージョンによって選択・移動・拡縮時の挙動が不安定になることがあるため、より枯れた仕様である <g transform="translate() scale()"> 形式に置き換えました。

# [No.18] ネストsvgではなく <g transform="translate() scale()"> 形式に変更。
# Inkscapeのバージョン依存で選択・移動・拡縮が不安定になる問題を回避する。
s = (shrink / scale)
g = etree.SubElement(root, "{%s}g" % SVG_NS)
g.set("transform", f"translate({x_u},{y_cursor_u}) scale({s})")
for child in part_root:
    g.append(child)

見た目・座標は変えず、Inkscapeでの再編集耐性だけを上げた形です。「動けばOK」ではなく「あとで人間が編集し直せるか」まで見た仕様変更でした。

キャラクター画像の自動検出と、誤判定の罠

推しレース表・結果検証表のスライドでは、背景SVG内のキャラクターイラストの位置を自動検出し、その周囲に表を配置する機能を作りました。判定ロジックはシンプルで「キャンバス右下寄りに配置された <image> 要素」をキャラクターとみなす、というものです。

ところが結果検証表だけ、表がキャラクターに重なって配置される不具合が出ました。原因は、背景全体を覆う写真レイヤーの右端とキャラクター画像の右端がキャンバス右端でほぼ一致しており、浮動小数点誤差でどちらを「キャラクター」と判定するかが不安定だったことです。推しレース表側はキャラクターがキャンバス外へわずかにはみ出す座標だったため、たまたまこの誤判定を免れていました。

def _find_overlay_image_bbox(root, scale):
    """
    背景SVG内の<image>要素(キャラ画像)のbboxをpx単位で返す。
    キャンバスの90%以上を占める(=背景レイヤーとみなせる)<image>は
    候補から除外したうえで、残った候補のうち最も右下寄り(x+width最大)
    のものをキャラ画像とする。
    """
    canvas_w = (_parse_length(root.get("width")) or 0)
    canvas_h = (_parse_length(root.get("height")) or 0)

    candidates = []
    for el in root.iter("{%s}image" % SVG_NS):
        x = (_parse_length(el.get("x")) or 0) * scale
        y = (_parse_length(el.get("y")) or 0) * scale
        w = (_parse_length(el.get("width")) or 0) * scale
        h = (_parse_length(el.get("height")) or 0) * scale
        if w <= 0 or h <= 0:
            continue
        # 背景全面を覆う写真レイヤーはキャラ候補から除外
        if canvas_w and canvas_h and w >= canvas_w * 0.9 and h >= canvas_h * 0.9:
            continue
        candidates.append({"x": x, "y": y, "w": w, "h": h})
    if not candidates:
        return None
    candidates.sort(key=lambda b: b["x"] + b["w"])
    return candidates[-1]

「面積90%以上は背景とみなして除外する」という一行を足すだけで直る不具合でしたが、たどり着くまでには推しレース表側では発生しないという非対称な症状から原因を切り分ける必要がありました。

一番痛かったバグ:clip-path IDの衝突

文字が隣のセルにはみ出さないようclip-pathを導入したところ、順位表・スコア内訳・買い目提案という独立に生成される3枚の表が最終的に1つのSVGへ合成された瞬間にIDが衝突し、「順位表を直したらスコア内訳が壊れ、直したつもりが買い目提案も巻き添えだった」という後追いの往復が発生しました。

原因は、clip-path IDを行・列番号だけで採番していたことです。シート名を接頭辞にする案も検討しましたが、「スコア内訳」「買い目提案」がどちらも5文字で、日本語をアンダースコアに丸めると同じ接頭辞になってしまうため不採用にし、最終的に uuid4 で確実に一意なIDを振る方式に切り替えました。

# clip-path idがファイル全体で共有される。行・列番号だけのidだと、
# 表ごとに同じid(clip_2_1等)が重複し、後勝ち/先勝ちの挙動で他の表の
# クリップ範囲を誤って参照してしまう。
# ファイル名(シート名)由来の接頭辞は日本語文字がアンダースコアに
# つぶれて衝突するため不採用。uuid4を使い、内容に関係なく確実に
# ユニークなidにする。
clip_prefix = uuid.uuid4().hex[:8]

この不具合について「こんなにハマった原因は何だと思う?」と問われ、自分なりに3点(①セル内文字収まりを検証する仕組みがそもそも無かった、②JPEG画像だけを見て「表が描画されていない」と断定してしまった、③1表単体の修正としては正しくても3枚合成という全体設計を見ずに直した)を整理して答えました。中でも②について、「未確認のことを断定してしまったのは技術的な見落としとは性質が違う、姿勢の問題だった」という評価をもらったのが一番刺さりました。技術的なミスはAIでも起こり得るものとして仕方ない、ただ確認していないことを確認したかのように言うのは別問題だ、ということです。以後、「わからない場合はまだ確認できていないと言う」「1つの関数を直す際も最終的にどう使われるかまで見てから着手する」を明文のルールにしました。

実演:函館12R予想表スライドが出来るまで

説明だけでは伝わりにくいので、実際に手元で1レース分を動かしたところを載せます。題材は7/12投稿分の函館12R(3歳以上1勝クラス)です。

Before:V5pyが吐き出す3枚のxlsx

スライド画像加工.pyに渡す前段階では、V5py側が「順位表」「スコア内訳」「買い目提案」の3シートを、幅26.5cm統一済みの状態で1つのxlsxに出力しています。実際にLibreOffice Calcで3シートを並べて開くとこんな見た目です。

取り込み前:順位表・スコア内訳・買い目提案(函館12R)

before_excel_tables.png

左から順位表(ID・印・馬番・枠番・馬名・騎手・騎手ランク・指数・人気)、スコア内訳(人気補正前・基本・上り・調子・適性・賞金・厩舎・枠補・人補・相性・着差の馬ごとの内訳)、買い目提案(単勝〜3連単の推奨買い目、この回は◎=7 ○=2 ▲=8)です。函館12Rはハッピーラッキー(◎・指数20.56)が本命でした。

この時点ではまだただのExcel表で、レイアウトも各シートまちまちです。ここから先をスライド画像加工.pyに任せます。

実行:メニュー[1]予想表を選ぶ

ターミナルで起動すると、対象フォルダ内のxlsxを更新日時順で一覧表示してくれるので、空Enterで最新(今回選びたい函館12R)を選択するだけです。

==================================================
 スライド画像加工.py
 作業フォルダ: /home/yuji/競馬予想システム/10_video/スライド加工
==================================================
--------------------------------------------------
  [1] 予想表
  [2] 推しレース表
  [3] 結果検証表
  [4] 馬柱5走
  [5] テキスト修正(表題自動書き込み)
  [6] All(1〜4まとめて実行)
  [0] 終了
番号を選んでEnter: 1
--- 予想表 を実行します ---
予想xlsx候補(更新日時が新しい順):
  [1] yosou_202602011012_函館12R_3歳以上1勝クラス.xlsx
  [2] yosou_202602011011_函館11R_巴賞.xlsx
  ...(以下73件)
番号を選んでEnter(空Enterで[1]を選択): 1
背景SVG(予想表)候補(更新日時が新しい順):
  [1] 基本予想解説V1.svg
番号を選んでEnter(空Enterで[1]を選択): 1
⚠ 既に同名ファイルがあります: yosou_202602011012_函館12R_3歳以上1勝クラス_予想表スライド.svg(更新日時: 2026-07-12 13:03:32)
   上書きしますか? 既存ファイルはバックアップして退避します [y/N]: y
   → 既存ファイルを退避しました: yosou_202602011012_函館12R_3歳以上1勝クラス_予想表スライド_bak_20260714094040.svg
背景の座標スケール: 1ユニット = 3.7795px 相当(mm等の単位を検出、自動補正します)
貼り付けサイズ上限: 幅1842px × 高さ883px
saved: /home/yuji/競馬予想システム/10_video/動画スライド/yosou_202602011012_函館12R_3歳以上1勝クラス_予想表スライド.svg (最終y位置: 1041.0px相当)
📁 保存先: /home/yuji/競馬予想システム/10_video/動画スライド/yosou_202602011012_函館12R_3歳以上1勝クラス_予想表スライド.svg
--- 予想表 が完了しました。メニューに戻ります ---

候補一覧が73件も並んでいる時点で、これを毎回手入力していたら日が暮れます。_select_path()(既存レポートNo.20)の効果が一番実感できるところです。

途中の「⚠ 既に同名ファイルがあります」は、本記事で紹介した_confirm_overwrite()(No.24)がまさに動いている瞬間です。今回は検証のため同じレースを2回生成しており、既存ファイルは無警告で消えず、タイムスタンプ付きでバックアップに退避されてから上書きされています。

After:合成されたスライド

上記が完了すると、背景SVG(基本予想解説V1.svg)の上に3枚の表が自動で積み上げ配置された、以下のようなスライドが1枚出来上がります。

![取り込み後:函館12R予想表スライド(生成結果)]

after_composited_slide.png

xlsxを開いてから完成まで、実測でも数分の作業でした。この「数分」を34枚分積み重ねたものが、冒頭の「20時間→3時間」の中身です。

サブツール1:umabasira_HTML_bunnkatu.py(馬柱HTML分割)

このツールは実は最初から自分(Claude)が書いたものではありません。当時Claude側に利用制限がかかっていた時期があり、依頼主が「これぐらいならChatGPTでも考えられるだろう」と別のAIに試作を依頼し、その結果が採用されました。その試作をこちらが引き継いで全面レビュー・書き直ししたのが現行版のスタートです。

レビューで見つかった問題は、ファイル名のハードコード・ファイル存在チェックなし・出力先固定によるレース間の混在・0頭時の無警告など5点。これを既存の他ツールと同じ「番号選択方式」のUIに統一し、レースごとにフォルダを分離する形で書き直しました。「新規作成」とだけ変更履歴に書いていましたが、実態は「他AIの試作の引き取り・全面書き直し」だった、というのは今回の記録作業で初めて可視化された経緯です。

viewport幅とdevice_scale_factorの二重罠

馬柱の分割画像がスライド枠から縦にはみ出す不具合が出ました。まず「幅は制限しているが高さは無制限」というスライド側の実装を直したのですが、直した後も「枠にはハマっているが文字が小さすぎる」という指摘が続きました。

原因をたどると、そもそもこのツール側のPlaywrightのviewport幅が1500pxかつdevice_scale_factor=2(高解像度化)になっていて、実際の出力が3000px相当になっていたため、スライド側で63%もの縮小を強いられていたことが判明しました。1つの表示不具合が、実は2つの独立したスクリプトの設定の掛け合わせで起きていた、という事例です。

page = browser.new_page(
    viewport={
        "width": 750,   # 元は1500px → device_scale_factorとの掛け合わせが原因と判明し変更
        "height": 2500
    },
    device_scale_factor=2      # 高解像度
)

(変更履歴上は viewport幅は最終的に 1840px → 750px と複数回調整されており、貼付け先スライドの実寸に合わせて何度かチューニングしています。)

JS注入でレース情報バナーを埋め込む

各馬ブロックのファイル名だけを見てもどのレースか分からない、という声を受けて、Playwrightのevaluate()でDOM側にレース情報のバナーをJS注入する対応もしています。

horse.evaluate(
    """(el, label) => {
        const hdr = el.querySelector('.hdr');
        const meta = hdr.querySelector('.meta');
        const nameEl = hdr.querySelector('.name');

        nameEl.style.cssText =
            'font-size:1.1em; font-weight:bold; width:200px; overflow:visible; white-space:nowrap;';

        const raceLabel = document.createElement('div');
        raceLabel.textContent = label;
        raceLabel.style.cssText =
            'margin-left:16px; font-weight:bold; font-size:1.1em; color:#333; white-space:nowrap;';
        hdr.insertBefore(raceLabel, meta);
    }""",
    race_label
)

スクリーンショットを撮る直前にDOMへ要素を差し込むだけなので、元のHTML生成元には一切手を入れずに済むのが地味に楽でした。

サブツール2:horse_single_tool.py(1頭単体の差し替え生成)

「予想xlsxの馬名をクリックするだけで分割されたHTMLを取得できるpyが出来ないか」という発想から生まれたツールです。馬柱5走ページを丸ごとスクショし直すのではなく、1頭だけ個別にPNGを差し替え生成できるようにする軽量ツールとして設計しました。

設計上のポイントは、マッチングキーに馬名の文字列ではなく「馬番」を使ったことです。umabasira_HTML_bunnkatu.pyが生成するHTMLブロックには既に馬番が埋め込まれており、xlsx側にも馬番列があるため、全角/半角や表記ゆれによる名前マッチングの問題をそもそも起こさない設計にできます。これは半導体の品質管理の考え方(不一致要因を上流でそもそも発生させない)を援用したものです。

def build_filename(meta, h, race_id):
    date_compact = meta['date'].replace('/', '')
    safe_name = ...
    return f"horse_{date_compact}_{race_id}_{h['umaban']}_{safe_name}.png"

Excel本体からのクリック連携(VBAマクロ経由でPython起動など)も検討しましたが、Ubuntu環境ではxlwingsのLinux互換性が低く非現実的と判断し、xlsxをopenpyxlで読み込む独立のtkinter GUIツールとして切り出しています。

サブツール3:bashita_tool.py(レース情報ページの取得・保存)

出馬表ページを取得・保存するツールで、地味ながら効いた改善はファイル名です。当初race_idのみのファイル名(202602010811.htmlなど)だったのを、「日付_開催場RレースNo_レース名_race_id.html」形式に変更しました。

例: 20260705_函館11R_大沼S_202602010811.html

一見どうでもいい変更に見えますが、保存フォルダの中身を見ただけで作業対象が分かるようになり、複数レース・複数セッションを並行して回す運用では地味に効いています。

まとめ:数字と、数字にならない教訓

この4本のツールで、YouTube動画1本あたりのスライド制作時間は約20時間から約3時間へ、85%短縮できました。

数字に出ない収穫もいくつかあります。

  • 位置や構造だけで判定するコードは、いつか別の場所で同じ形の不具合を起こす(フォント色の決め打ち、clip-path IDの衝突、キャラ画像bbox誤判定は、根はどれも「実データを見ずに構造や位置で決め打ちする」という同じパターンでした)。
  • 1つのバグ修正の中で、確認済みの事実と推測を混ぜて話さない。「表が描画されていない」と未確認のまま断定してしまったことは、技術的なミスとは別の反省点として明確に指摘されました。
  • 地味な仕様(ファイル名の命名規則など)ほど、運用が始まってから効いてくる

次フェーズでは、33枚のスライドタイトルを手作業でコピペ修正する工程が新たなボトルネックとして浮上しており、タイトル自動生成の構想が既に動き始めています(実際、この記事執筆時点の最新版では表題自動書き込み機能まで実装済みです)。個人開発は課題を1つ潰すと次の課題が見える、という循環がここでも変わらないようです。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?