はじめに
このお盆休み期間でCodexと相談しながら、これまで描いてきた4コマ漫画をまとめて読める「kugi4koma」というサイトを作り始めました。

今回の記事は「4コマ漫画ビューワーの準備」編になります。
経緯
「kugi日記」のはじまり
これまでの年越し振り返り記事などでも触れてきましたが、 私は上京して社会人になったタイミングで不定期で日常の4コマ漫画を描くようになりました。
きっかけは新生活の準備のために無印良品へ行った際に見かけた4コマノートです。
それまでお絵描きをしたことはなかったものの、自分で何か描いてみたいという気持ちはずっとあり、 ちょうど新生活が始まるタイミングでもあったので、ゆる~く描いてみようと思い、 日常の出来事を書き始めました。
更新頻度は年々落ちているのですが、ネタ帳には題材を書き溜めつつ、 「描きたいときに描く」をモットーにゆる~く描いています。
そんなこんなで5年半が過ぎ、先日4コマノートの折り返し地点まで来ました。
5年半かけてようやく折り返しです
— kugi (@kugi_masa) 2026年8月15日
引き続きゆるゆると描いていきます#4コマ日記 #4コマ漫画#kugi日記 https://t.co/uHxenRu4pb pic.twitter.com/sSm1X9SoB3
公開までの準備
描いた漫画は
- 家族のLINEアルバム
- Instagramのストーリーズ
- X (旧Twitter)
- UGDG slackの個人timesチャネル
- Note | kugi日記
などいろいろな場所で公開しているのですが、アナログで書いた原稿を公開するまでの準備が何気に大変です。
まずは画像化したいので、最初の頃はスマホのカメラやMicrosoft Lens (旧 Office Lens)などで撮影し、色調補正など微調整をした画像を使っていました。
Microsoft Lens の廃止 | Microsoft Learn (Microsoft Lensアプリは廃止されたみたいです...)
そこからプリンターでスキャンするようになり、スキャンしたデータをUSB経由でPCに取り込み、 コマごとに切り取りをしたうえで各SNSで公開するような流れを取っていました。

そもそも、ゆる~く描くことがモットーなのに、公開の準備に手間がかかってしまうのは本意ではありません。
スキャンしたデータをコマ割りを検出して、画像化して投稿の準備までしてくれたら楽なのに...
と何度も頭をよぎるのですが、如何せん4コマを描くペースも年々落ちているので、 何も考えずに画像の切り取りもアナログでやり過ごしていました。
ただ、いつか役に立つかも?という気持ちでスキャンついでにデータ整理はしていました。


Codexで試す夏
ちょうど先月とある場所でChatGPT Proの1か月無料コードをいただいたのにあまり使えていなかったのと、 お盆休みで時間もあったのでCodexでいろいろ試してみることにしました。

NVIDIAのハンズオン参加してきた
— kugi (@kugi_masa) 2026年7月20日
描画不具合、負荷調査もMCPやSkillを使う時代…!
ChatGPT Proの1 month freeコードも貰えた🙌 pic.twitter.com/S38nKA5RxN
それに合わせ、プリンターのデータ取り込みもUSBではなく、GoogleDrive連携に切り替えました。
【インクジェット/レーザー プリンター・スキャナー】クラウド接続機能の設定方法|ブラザー
(なぜ今までクラウド接続できると気づかなかったのか...)
方針の壁打ち
まずは状況と実装方針の整理もかねて壁打ちします。
元々の達成したかったことはスキャンした原稿を投稿用に自動整形でしたが、 ついでに「kugi日記」専用のビューワーサイトを作ってみたくなりました。

- データの準備
- 原稿画像の取り込み
- Web用に画像の軽量化
- キーワードなどの情報付与
- 4コマ漫画ビューワー
- 各話の一覧表示
- 1コマずつ順番に読む機能
- タイトルや公開年、キーワードでの検索
に分けて考えられそうです。
原稿画像のコマ検出と自動切り取り
元々手作業でやっていた作業の自動化です。
スキャンついでに画像データのフォルダ整理や命名もルール化していたのがここで役に立ちました!
YYYYMMDD_Title形式の作品フォルダーを探す- フォルダーと同名の全体画像を読み込む
- EXIF情報に従って画像の向きを補正する
- 画像をグレースケール化し、横方向の枠線を8本検出する
- 2本ずつ組み合わせて4つのコマ領域を求める
- 各コマを1200 × 800 pxへ整形する
- サムネイル、全体表示画像、
manifest.jsonを生成する
上記を行うPythonスクリプトを用意してもらいました。
normalize_images.py
"""Create web-ready four-panel comic assets from one complete-page scan.""" from __future__ import annotations import argparse import json import re from dataclasses import dataclass from datetime import datetime from pathlib import Path import numpy as np from PIL import Image, ImageOps EPISODE_PATTERN = re.compile(r"^(?P<date>\d{8})_(?P<title>.+)$") IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp"} PANEL_SIZE = (1200, 800) PANEL_MARGIN = 24 FULL_SIZE = (PANEL_SIZE[0], PANEL_SIZE[1] * 4) THUMBNAIL_SIZE = (600, 400) BACKGROUND = (255, 255, 255) # Frame detection tolerates slightly slanted, faint, or JPEG-compressed ink lines. DARK_THRESHOLD = 160 LINE_WINDOW = 17 MIN_FRAME_LINE_WIDTH_RATIO = 0.72 SOURCE_FRAME_PADDING = 6 @dataclass(frozen=True) class EpisodeSource: directory: Path episode_id: str published_at: str title: str full: Path @dataclass(frozen=True) class DetectedLine: y: int start_x: int end_x: int def image_files(directory: Path) -> list[Path]: return sorted( path for path in directory.iterdir() if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES ) def discover_episodes(source_root: Path) -> list[EpisodeSource]: """Discover episodes using only the image whose name matches its folder.""" episodes: list[EpisodeSource] = [] errors: list[str] = [] for directory in sorted(path for path in source_root.rglob("*") if path.is_dir()): match = EPISODE_PATTERN.fullmatch(directory.name) if not match: continue # Files named 1, 2, 3, and 4 are deliberately ignored. full_candidates = [ path for path in image_files(directory) if path.stem == directory.name ] if len(full_candidates) != 1: errors.append( f"{directory}: full image must match the folder name " f"(found {len(full_candidates)})" ) continue date_text = match.group("date") published_at = datetime.strptime(date_text, "%Y%m%d").date().isoformat() episodes.append( EpisodeSource( directory=directory, episode_id=directory.name, published_at=published_at, title=match.group("title"), full=full_candidates[0], ) ) if errors: raise ValueError("Invalid episode folders:\n- " + "\n- ".join(errors)) if not episodes: raise ValueError(f"No episode folders found under {source_root}") return episodes def open_rgb(path: Path) -> Image.Image: with Image.open(path) as image: oriented = ImageOps.exif_transpose(image) if oriented.mode in {"RGBA", "LA"}: rgba = oriented.convert("RGBA") background = Image.new("RGBA", rgba.size, (*BACKGROUND, 255)) background.alpha_composite(rgba) return background.convert("RGB") return oriented.convert("RGB") def longest_true_run(mask: np.ndarray) -> tuple[int, int]: """Return the start and exclusive end of the longest True run.""" padded = np.pad(mask.astype(np.int8), (1, 1)) transitions = np.diff(padded) starts = np.flatnonzero(transitions == 1) ends = np.flatnonzero(transitions == -1) if starts.size == 0: return (0, 0) widths = ends - starts index = int(np.argmax(widths)) return (int(starts[index]), int(ends[index])) def detect_horizontal_frame_lines(image: Image.Image) -> list[DetectedLine]: grayscale = np.asarray(ImageOps.grayscale(image)) dark = grayscale < DARK_THRESHOLD height, width = dark.shape half_window = LINE_WINDOW // 2 candidates: list[tuple[int, int, int]] = [] for y in range(half_window, height - half_window): # A short vertical window follows slightly slanted horizontal frame lines. horizontal_mask = dark[ y - half_window : y + half_window + 1 ].any(axis=0) start_x, end_x = longest_true_run(horizontal_mask) if end_x - start_x >= width * MIN_FRAME_LINE_WIDTH_RATIO: candidates.append((y, start_x, end_x)) clusters: list[list[tuple[int, int, int]]] = [] for candidate in candidates: if not clusters or candidate[0] > clusters[-1][-1][0] + 3: clusters.append([candidate]) else: clusters[-1].append(candidate) if len(clusters) != 8: raise ValueError( f"Expected 8 horizontal frame lines, detected {len(clusters)}" ) lines: list[DetectedLine] = [] for cluster in clusters: strongest = max(cluster, key=lambda item: item[2] - item[1]) center_y = (cluster[0][0] + cluster[-1][0]) // 2 lines.append( DetectedLine( y=center_y, start_x=strongest[1], end_x=strongest[2], ) ) return lines def detect_panel_boxes(image: Image.Image) -> list[tuple[int, int, int, int]]: lines = detect_horizontal_frame_lines(image) boxes: list[tuple[int, int, int, int]] = [] for index in range(0, 8, 2): top_line = lines[index] bottom_line = lines[index + 1] left = min(top_line.start_x, bottom_line.start_x) right = max(top_line.end_x, bottom_line.end_x) top = top_line.y bottom = bottom_line.y + 1 box = ( max(0, left - SOURCE_FRAME_PADDING), max(0, top - SOURCE_FRAME_PADDING), min(image.width, right + SOURCE_FRAME_PADDING), min(image.height, bottom + SOURCE_FRAME_PADDING), ) if box[2] <= box[0] or box[3] <= box[1]: raise ValueError(f"Invalid detected panel box: {box}") boxes.append(box) return boxes def normalize_panel(image: Image.Image) -> Image.Image: content_size = ( PANEL_SIZE[0] - PANEL_MARGIN * 2, PANEL_SIZE[1] - PANEL_MARGIN * 2, ) scale = min(content_size[0] / image.width, content_size[1] / image.height) resized_size = ( max(1, round(image.width * scale)), max(1, round(image.height * scale)), ) resized = image.resize(resized_size, Image.Resampling.LANCZOS) canvas = Image.new("RGB", PANEL_SIZE, BACKGROUND) offset = ( (PANEL_SIZE[0] - resized.width) // 2, (PANEL_SIZE[1] - resized.height) // 2, ) canvas.paste(resized, offset) return canvas def assemble_full_image(panels: list[Image.Image]) -> Image.Image: if len(panels) != 4: raise ValueError(f"Expected 4 normalized panels, found {len(panels)}") full = Image.new("RGB", FULL_SIZE, BACKGROUND) for index, panel in enumerate(panels): full.paste(panel, (0, index * PANEL_SIZE[1])) return full def save_webp(image: Image.Image, destination: Path, quality: int) -> None: destination.parent.mkdir(parents=True, exist_ok=True) image.save(destination, "WEBP", quality=quality, method=6) def process_episode( episode: EpisodeSource, source_root: Path, output_root: Path ) -> dict[str, object]: year = episode.published_at[:4] episode_output = output_root / year / episode.episode_id source_image = open_rgb(episode.full) try: panel_boxes = detect_panel_boxes(source_image) except ValueError as error: raise ValueError(f"{episode.full}: {error}") from error normalized_panels: list[Image.Image] = [] panel_paths: list[str] = [] for index, box in enumerate(panel_boxes, start=1): panel = normalize_panel(source_image.crop(box)) normalized_panels.append(panel) destination = episode_output / f"panel-{index}.webp" save_webp(panel, destination, quality=90) panel_paths.append(destination.relative_to(output_root).as_posix()) thumbnail = normalized_panels[0].resize( THUMBNAIL_SIZE, Image.Resampling.LANCZOS ) thumbnail_destination = episode_output / "thumbnail.webp" save_webp(thumbnail, thumbnail_destination, quality=84) full_destination = episode_output / "full.webp" save_webp( assemble_full_image(normalized_panels), full_destination, quality=90 ) return { "id": episode.episode_id, "title": episode.title, "publishedAt": episode.published_at, "year": year, "thumbnail": thumbnail_destination.relative_to(output_root).as_posix(), "panels": panel_paths, "fullImage": full_destination.relative_to(output_root).as_posix(), "sourceFolder": episode.directory.relative_to(source_root).as_posix(), "sourceImage": episode.full.name, "sourceDimensions": [source_image.width, source_image.height], "detectedPanelBoxes": [list(box) for box in panel_boxes], } def main() -> None: parser = argparse.ArgumentParser( description="Detect and normalize four panels from complete-page scans." ) parser.add_argument("source", type=Path, help="Root folder containing year folders") parser.add_argument("output", type=Path, help="Folder for generated assets") args = parser.parse_args() source_root = args.source.resolve() output_root = args.output.resolve() if source_root == output_root or source_root in output_root.parents: raise ValueError("Output must be outside the source folder") episodes = discover_episodes(source_root) manifest = [ process_episode(episode, source_root, output_root) for episode in episodes ] output_root.mkdir(parents=True, exist_ok=True) (output_root / "manifest.json").write_text( json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) print(f"Processed {len(manifest)} episodes into {output_root}") if __name__ == "__main__": main()
テスト用に7話(2025年と2026年分)をSampleDataに格納し、WebP形式で整形されたデータをProcessedDataへ配置します。
python normalize_images.py SampleData ProcessedData

できてる!
ひとまずこれまで手動でやっていたことはスクリプトに置き換えられそうです。
4コマ漫画ビューワーの試作
画像を用意できるようになったので、次は7作品を使ってビューワーサイトを作りました。
試作段階では、整形した画像と作品情報のJSONファイルをサイト内に置くシンプルな構成にしています。

「手描きの4コマを、1コマずつ。」
それっぽいけど...
サイトのタイトルや記載する文面はこちらで特に指示しなかったので、哀愁(AI臭)漂う文面になってしまいました。
「4コマ帖」(そもそも「帖」何て読むんだ...?)
見た目や文章は追々整えていくとして、ビューワーについても想定したものが出来上がってきました。

はじめはCodexがChatGPT Sitesを勧めてきたので、そちらでページを公開していましたが 後からCloudflare Workersへホスティングする構成へ変更しました。
今のところはまだ公開サイトにはしていません。
Codexとの付き合い方
kugi日記用のプロジェクトを作成し、作業ごとにWorkを分けるようにしてみました。

- 4コマ漫画サイトの方針策定
- 最初のWork
- ChatGPT Sitesで先ほどのビューワーの動作確認ができるところまで
- ページのホスティング手段を検討
- ChatGPT SitesからCloudflareへ移行した際のWork
- GitHubリポジトリはこのタイミングで作成した
- CloudflareとGitHubの連携
- CloudflareとGitHubリポジトリの連携した際のWork
- いくつか見た目の修正をしてもらい、Push時にPreview環境をビルドするように
- kugi's notebook - ブログ原稿
- 今書いているブログ記事の原稿を相談するWork
- 後述する作業レポートを見てもらいつつ原稿を書いてもらった
- 結局ほとんど自分で書いている
- 次の記事でこの記事を参考にしてもらったらもっとうまくいくかも?
- 雑メモ
- 後述する雑メモやタスクを投げて記録してもらうWork
作業レポート
それぞれ作業内容を記録しておくために、そのセッションのレポートをMarkdown形式でまとめてもらうSkillsを作りました。

write-session-report/SKILL.md
--- name: write-session-report description: Create or update a Markdown work report that records the decisions, implementation changes, validation results, current state, and next steps from a completed Codex session. Use when the user asks to wrap up, close, hand off, summarize, or document a work session; requests a dated report such as YYYYMMDD_REPORT.md; or wants a project-local session record under docs or a similar documentation folder. --- # Write Session Report Create a durable, evidence-based record of the session for a future reader who does not have the conversation history. ## Workflow 1. Determine the report location and date. - Follow an explicit path or filename from the user. - Otherwise inspect existing project reports and use their directory, naming convention, language, and level of detail. - If no convention exists, use `docs/YYYYMMDD_REPORT.md` with the user's local date. - Create a new report by default. Update an existing same-day report only when it clearly covers the same work session. 2. Gather evidence before writing. - Review the current conversation, completed plans, tool results, and user confirmations. - Inspect relevant existing reports and project documentation. - For a Git repository, inspect status, recent commits, tracked files, ignore rules, and remote state when relevant. - Inspect configuration or source files when exact current values matter. - Distinguish completed work, verified results, user decisions, remaining work, and suggested next steps. 3. Protect sensitive and excluded information. - Do not include secrets, tokens, credentials, private personal data, or unnecessary account identifiers. - Do not assume that writing an ignored report authorizes changing ignore rules, committing, pushing, deploying, or publishing. - Preserve the project's existing exclusions and privacy decisions. 4. Write for continuity. - Start with the project, report date, purpose, and important destinations or environments. - Record decisions and operating rules that future work must preserve. - Summarize material changes by area or commit, using exact identifiers only when verified. - Record validation commands conceptually and their results; include useful measurements when known. - Add a compact current-state table when several environments or milestones differ. - State unresolved risks, limitations, and next steps in priority order. - Call out easily confused states explicitly, such as Preview versus production, local versus pushed, or prepared versus deployed. - Match the user's language and the style of existing reports. 5. Verify the artifact. - Re-read the complete report for internal consistency. - Confirm dates, paths, URLs, branch names, commit IDs, counts, and environment states against available evidence. - Label anything not directly verified as an inference, estimate, or proposed next step. - Confirm the file exists at the requested location. - Check repository status afterward and report whether the new file is tracked, ignored, or untracked. 6. Hand off concisely. - Link the created report using its absolute local path. - Summarize its main coverage and Git tracking state. - State explicitly that no commit, push, deployment, or publication occurred unless it actually did. ## Recommended Sections Adapt the sections to the work; omit empty sections and add domain-specific sections when useful. - Purpose - Decisions and operating policy - Work completed - Commits or changed files - Environment, hosting, or deployment state - Validation results - Current state - Remaining work and next steps - Cautions and exclusions ## Quality Rules - Prefer verified facts over a chronological chat transcript. - Keep enough detail to resume work without reopening the full session. - Do not claim success from an attempted command; require a successful result or user confirmation. - Do not silently convert plans into completed work. - Do not expose credentials or paste verbose logs. - Avoid duplicating a prior report wholesale; focus on changes and decisions from the current session while preserving necessary context.
雑メモ
ふと思ったことや改善したいことなどを作業中のWorkとは別の場所で投げておくWorkを用意しました。
SCRAP_MEMO.mdに書き残してくれます。
落ち着いたタイミングで現状と照らし合わせてリストにチェックをつけてもらうようにしています。

SCRAP_MEMO.md
# 雑メモ 雑なメモを書き溜めておくファイルです。 定期的にプロジェクトの状況と照らし合わせ棚卸しを行います。 ## UI・表示 - コマ数表示がコマと被っているため、配置を見直す。 - 「1コマずつ読む」の記載は冗長なので、「読む」に変更する。 - 「作品一覧」を押しても何も遷移しないため、リンク先・画面遷移を確認する。 ## 共有 - Xへポストするボタンを追加する。 - 他のSNSへの共有リンクも追加する。 ## サイト情報 - サイトのタイトルを「kugi4koma」に変更する。 - 作者についてのページを追加する。 ## データ整形 - データ整形時にコマの色味を調整できるようにする。 - comicのデータやリソースが`site/public`以下に配置されている。GitHubへのコミットや今後の管理を考慮し、配置・保管方法の代替案を検討する。 - 作品追加時に多言語対応するワークフローを検討する。 ## 文言・コンテンツ - 「手描きの時間を、画面の向こうへ。」「手描きの4コマを、1コマずつ。日々の出来事や思いつきを描いた4コマ漫画をまとめています。気になる作品を選んで、ゆっくりお楽しみください。」など、本人の言葉ではない文言を修正する。 - 上記以外にも、意図せず追加された文言がないか確認する。 ## 権利・ライセンス - 4コマ漫画の権利表記を追加する。 - サイト実装に用いたOSSなどのライセンス表記を追加する。 ## 運用・公開 - Cloudflareでデプロイする。 - [x] GitHubでプロジェクトを管理する。完了: 2026-08-16 - [x] Cloudflareでの本番デプロイとPreviewの違いを確認する。完了: 2026-08-16 - カスタムドメインを取得する。 - ChatGPT Sitesの残骸(`.openai`など)を確認し、不要なものを削除する。 - GitHubへのコミット・プッシュ手順とブランチ戦略を整理する。 ## ドキュメント・記録 - セッション後の作業レポートをObsidianに登録できるようにする。
完全にメモ代わりとして使っている。
なので「雑メモ」
まとめ
今回は、「4コマ漫画ビューワーの準備」編ということで、 「kugi日記」の公開準備の一部自動化とビューワーの試作版をCodexと共に作りました。
まだ7作品だけの試作版ですが、全体スキャン画像から公開用画像を作り、作品一覧と1コマずつ表示するところまでは一通りつながりました。
普段からWeb開発しているわけでもないので勉強にもなりました。
実際のサイト公開まではもう少しやることがあるので続編でお会いしましょう。
それでは!(ミーンミンミンミン)
















































