diff --git a/.gitignore b/.gitignore index 4a6fabb..46ae9e3 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,10 @@ desktop.ini # Internal reference images docs/reference/* !docs/reference/.gitkeep +!docs/images/*.png +!src/lightwatch/assets/ +!src/lightwatch/assets/sample-images/ +!src/lightwatch/assets/sample-images/*.png *.egg-info/ # Frontend frontend/node_modules/ diff --git a/README.md b/README.md index c655308..00ee143 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,30 @@ LightWatchは、Raspberry Piと対応カメラを使用して室内照明の状 観測結果をSQLiteへ保存し、状態変化時や定時の画像を保持します。保存画像のNAS同期、Slack通知、定期レポート、Health Check、FastAPIとReactによるWeb UIをsystemdで運用できます。 +## Web UI + +照明状態、最新画像、状態タイムライン、保存画像、観測履歴をWebブラウザから確認できます。 + +掲載している画面は、ローカルWebプレビュー用のサンプルデータとダミー画像を使用しています。 + +### 照明状態とタイムライン + +![LightWatchの照明状態とタイムライン](docs/images/lightwatch-dashboard.png) + +最新の照明状態、判定値、Health Check、NAS同期状況と、1日の状態変化をタイムラインで表示します。 + +### 保存画像と判定詳細 + +![LightWatchの保存画像と判定詳細](docs/images/lightwatch-image-archive.png) + +保存された画像を状態別に確認し、選択した画像の判定理由や照度指標を表示できます。 + +### 観測履歴 + +![LightWatchの観測履歴](docs/images/lightwatch-observations.png) + +観測結果を時系列で一覧表示し、各観測の判定詳細を展開できます。 + ## 主な機能 - Raspberry Piカメラによる定期撮影 @@ -18,9 +42,10 @@ LightWatchは、Raspberry Piと対応カメラを使用して室内照明の状 - 撮影、NAS同期、ディスク容量などのHealth Check - FastAPI + ReactによるWeb UI - タイムライン表示と画像履歴閲覧 -- 画像モーダルと前後画像への移動 +- 保存画像の選択と判定詳細表示 - `flock` による撮影とライブビューの排他制御 - systemd service / timerの設定例 +- 本番環境を使用しないローカルWebプレビュー 同梱の設定例では、5分ごとの撮影、毎時10分のNAS同期、毎時15分のHealth Check、06:30・12:30・18:30の定期レポートを使用します。必要に応じて設定ファイルとsystemd timerを変更してください。 @@ -138,9 +163,18 @@ systemdの導入と確認は [systemd運用](docs/systemd.md) を参照してく ## ローカルWebプレビュー -Raspberry Pi、本番DB、NAS、Slack、カメラを使用せず、サンプルDBと画像を生成してWeb UIを確認できます。 +Raspberry Pi、本番DB、NAS、Slack、カメラを使用せず、サンプルDBとダミー画像を生成してWeb UIを確認できます。 -開発用設定を作成します。 +サンプル画像には、次の4状態に対応した架空のオフィス画像を使用します。 + +- `BRIGHT` +- `PARTIAL` +- `DIM` +- `DARK` + +実在するオフィス、人物、機密情報、実運用の撮影画像は含まれていません。 + +### 1. 開発用設定の作成 ```powershell Copy-Item ` @@ -148,13 +182,29 @@ Copy-Item ` .\config\development.toml ``` -サンプルデータを生成します。 +### 2. サンプルデータの生成 ```powershell uv run --locked python .\scripts\create-web-sample-data.py ``` -Web UIを起動します。 +既存のサンプルデータを作り直す場合: + +```powershell +uv run --locked python .\scripts\create-web-sample-data.py --force +``` + +生成先は既定で次の場所です。 + +```text +.local/web-preview/ +├─ timelapse.db +└─ images/ +``` + +`.local/` はGit管理対象外です。 + +### 3. バックエンドの起動 ```powershell $env:LIGHTWATCH_CONFIG = "config/development.toml" @@ -165,7 +215,27 @@ uv run --locked python -m uvicorn lightwatch.web:app ` --reload ``` -ブラウザで `http://127.0.0.1:8000` を開きます。 +APIの確認先: + +```text +http://127.0.0.1:8000/api/status +``` + +### 4. フロントエンドの起動 + +別のPowerShellを開いて実行します。 + +```powershell +Push-Location frontend +npm run dev +Pop-Location +``` + +ブラウザで、Viteが表示したURLを開きます。通常は次のURLです。 + +```text +http://127.0.0.1:5173 +``` 詳細は [ローカルWebプレビュー](docs/local-web-preview.md) を参照してください。 @@ -190,6 +260,33 @@ uv run --locked python -m uvicorn lightwatch.web:app ` 同梱のROI座標と閾値は2304×1296画像向けのサンプルです。実際のカメラ位置、画角、照明配置に合わせて調整してください。 +## プロジェクト構成 + +```text +lightwatch/ +├─ config/ +│ ├─ default.toml +│ ├─ development.toml.example +│ └─ production.toml.example +├─ docs/ +│ └─ images/ +├─ frontend/ +├─ scripts/ +├─ src/ +│ └─ lightwatch/ +│ └─ assets/ +│ └─ sample-images/ +├─ systemd/ +├─ tests/ +├─ pyproject.toml +├─ uv.lock +└─ VERSION +``` + +`src/lightwatch/assets/sample-images/` の画像は、ローカルWebプレビュー用のサンプルデータ生成に使用します。 + +`docs/images/` の画像は、READMEやドキュメントへ掲載する画面キャプチャです。 + ## ドキュメント | 資料 | 内容 | @@ -209,14 +306,20 @@ LightWatchのWeb UIには、アプリケーション単体でのユーザー認 - `.env` とSlack認証情報 - 本番用設定ファイル - SSH秘密鍵とNAS認証情報 -- 撮影画像 +- 実運用の撮影画像 - SQLiteデータベース - 状態ファイル - ログ - `.venv/` - `frontend/node_modules/` +- `frontend/dist/` - ローカルWebプレビュー生成物 +次の画像は、公開用サンプルとして意図的にGit管理します。 + +- `src/lightwatch/assets/sample-images/*.png` +- `docs/images/*.png` + 脆弱性の報告方法とサポート対象バージョンは [SECURITY.md](SECURITY.md) を参照してください。 ## コントリビューション diff --git a/docs/images/lightwatch-dashboard.png b/docs/images/lightwatch-dashboard.png new file mode 100644 index 0000000..530ee7e Binary files /dev/null and b/docs/images/lightwatch-dashboard.png differ diff --git a/docs/images/lightwatch-image-archive.png b/docs/images/lightwatch-image-archive.png new file mode 100644 index 0000000..fc51b2d Binary files /dev/null and b/docs/images/lightwatch-image-archive.png differ diff --git a/docs/images/lightwatch-observations.png b/docs/images/lightwatch-observations.png new file mode 100644 index 0000000..39da2e7 Binary files /dev/null and b/docs/images/lightwatch-observations.png differ diff --git a/pyproject.toml b/pyproject.toml index 97e8d42..c842648 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,9 @@ package-dir = { "" = "src" } [tool.setuptools.packages.find] where = ["src"] +[tool.setuptools.package-data] +lightwatch = ["assets/sample-images/*.png"] + [tool.ruff] line-length = 88 target-version = "py311" diff --git a/src/lightwatch/assets/sample-images/bright.png b/src/lightwatch/assets/sample-images/bright.png new file mode 100644 index 0000000..e0ad10a Binary files /dev/null and b/src/lightwatch/assets/sample-images/bright.png differ diff --git a/src/lightwatch/assets/sample-images/dark.png b/src/lightwatch/assets/sample-images/dark.png new file mode 100644 index 0000000..708dd28 Binary files /dev/null and b/src/lightwatch/assets/sample-images/dark.png differ diff --git a/src/lightwatch/assets/sample-images/dim.png b/src/lightwatch/assets/sample-images/dim.png new file mode 100644 index 0000000..2206eee Binary files /dev/null and b/src/lightwatch/assets/sample-images/dim.png differ diff --git a/src/lightwatch/assets/sample-images/partial.png b/src/lightwatch/assets/sample-images/partial.png new file mode 100644 index 0000000..6528e70 Binary files /dev/null and b/src/lightwatch/assets/sample-images/partial.png differ diff --git a/src/lightwatch/sample_data.py b/src/lightwatch/sample_data.py index 12d3894..6469d3b 100644 --- a/src/lightwatch/sample_data.py +++ b/src/lightwatch/sample_data.py @@ -1,21 +1,36 @@ from __future__ import annotations import argparse -import base64 import shutil import sqlite3 from contextlib import closing from dataclasses import dataclass from datetime import date, datetime, time, timedelta +from importlib.resources import files from pathlib import Path +from PIL import Image + from .database import record_capture from .nas_sync_state import NasSyncState, save_nas_sync_state DEFAULT_OUTPUT_DIR = Path(".local/web-preview") -_SAMPLE_JPEG = base64.b64decode( - "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAF//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABBQJ//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPwF//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPwF//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQAGPwJ//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPyF//9oADAMBAAIAAwAAABAf/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPxB//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPxB//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPxB//9k=" -) +_SAMPLE_IMAGE_FILENAMES = { + "BRIGHT": "bright.png", + "PARTIAL": "partial.png", + "DIM": "dim.png", + "DARK": "dark.png", +} + + +def _write_sample_image(destination: Path, state: str) -> None: + resource = ( + files("lightwatch") + .joinpath("assets", "sample-images") + .joinpath(_SAMPLE_IMAGE_FILENAMES[state]) + ) + with resource.open("rb") as source, Image.open(source) as image: + image.convert("RGB").save(destination, format="JPEG", quality=90) @dataclass(frozen=True) @@ -94,7 +109,7 @@ def create_sample_data( file_size = None if index in saved_indexes: image_path = image_dir / f"{captured_at:%Y%m%d_%H%M%S}_{state.lower()}.jpg" - image_path.write_bytes(_SAMPLE_JPEG) + _write_sample_image(image_path, state) file_size = image_path.stat().st_size saved_count += 1 diff --git a/tests/test_sample_data.py b/tests/test_sample_data.py index 8d1b52d..b8db52f 100644 --- a/tests/test_sample_data.py +++ b/tests/test_sample_data.py @@ -3,6 +3,7 @@ import pytest from fastapi.testclient import TestClient +from PIL import Image from src.lightwatch.sample_data import create_sample_data from src.lightwatch.web import create_app @@ -50,6 +51,29 @@ def test_create_sample_data_supports_web_preview(tmp_path: Path) -> None: assert status["nas_sync"]["result"] == "success" +def test_create_sample_data_uses_state_specific_images(tmp_path: Path) -> None: + output_dir = tmp_path / "preview" + result = create_sample_data(output_dir, target_date=date(2026, 7, 26)) + + image_paths = sorted(result.image_dir.glob("*.jpg")) + assert len(image_paths) == 8 + + dimensions = set() + representative_pixels = {} + for image_path in image_paths: + state = image_path.stem.rsplit("_", 1)[-1].upper() + with Image.open(image_path) as image: + assert image.format == "JPEG" + dimensions.add(image.size) + representative_pixels.setdefault( + state, image.resize((1, 1)).getpixel((0, 0)) + ) + + assert dimensions == {(1672, 941)} + assert set(representative_pixels) == {"BRIGHT", "PARTIAL", "DIM", "DARK"} + assert len(set(representative_pixels.values())) == 4 + + def test_create_sample_data_requires_force_to_replace(tmp_path: Path) -> None: output_dir = tmp_path / "preview" create_sample_data(output_dir, target_date=date(2026, 7, 26))