diff --git a/.gitignore b/.gitignore index 1be4d47..f365d60 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,9 @@ # ローカル専用の参考素材(Claude への状況説明用サンプル画像など.Git には含めない) local_reference/ +# ローカル専用のリリース反映スクリプト(研究室サーバ URL を含むため Git に載せない) +scripts/release-push.ps1 + # Claude Code .claude/plans/ diff --git a/CLAUDE.md b/CLAUDE.md index 0f57249..563d131 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,7 @@ - 画面機能仕様書(画面・遷移・UI・パーミッション): docs/04_SPEC/SPEC_01_画面機能仕様書.md - カメラ撮影仕様書(カメラ制御・撮影フロー・ファイル管理): docs/04_SPEC/SPEC_02_カメラ撮影仕様書.md - アーキテクチャと非機能(コード構成・非機能要件): docs/04_SPEC/SPEC_03_アーキテクチャと非機能.md +- プレビュー向きとクロップ(反転・クロップの座標系・変更時の確認手順): docs/04_SPEC/SPEC_04_プレビュー向きとクロップ.md ### 05_TECH(技術設計・外部要求仕様) diff --git a/assets/config/preview_region.json b/assets/config/preview_region.json new file mode 100644 index 0000000..f6521e5 --- /dev/null +++ b/assets/config/preview_region.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "preview_region": { + "left": 0.244, + "top": 0.2718, + "right": 0.747, + "bottom": 0.7889 + }, + "note": "撮影画面プレビュー領域に対する正規化座標(0-1)。CameraPreviewWidget が表示後の画面座標でクロップする(WYSIWYG)。アプリ内キャリブレーションで実機調整した確定値。" +} \ No newline at end of file diff --git "a/docs/04_SPEC/SPEC_02_\343\202\253\343\203\241\343\203\251\346\222\256\345\275\261\344\273\225\346\247\230\346\233\270.md" "b/docs/04_SPEC/SPEC_02_\343\202\253\343\203\241\343\203\251\346\222\256\345\275\261\344\273\225\346\247\230\346\233\270.md" index a649254..b7bfc1b 100644 --- "a/docs/04_SPEC/SPEC_02_\343\202\253\343\203\241\343\203\251\346\222\256\345\275\261\344\273\225\346\247\230\346\233\270.md" +++ "b/docs/04_SPEC/SPEC_02_\343\202\253\343\203\241\343\203\251\346\222\256\345\275\261\344\273\225\346\247\230\346\233\270.md" @@ -49,12 +49,14 @@ ### プレビュー表示 +プレビューの**向き変換(反転)とクロップ拡大**の詳細・座標系・変更時の確認手順は +[SPEC_04 プレビュー向きとクロップ](SPEC_04_プレビュー向きとクロップ.md) に分離した(本書より優先). + +概要のみ: + - `camera` パッケージの `CameraPreview` ウィジェットを使用する -- UI 全体を `Transform.rotate(angle: pi)` で 180° 回転する -- カメラ映像に `Matrix4.diagonal3Values(-scale, -scale, 1.0)` を適用する - - X 軸反転: 鏡像表示(操作者が自然に見える向き) - - Y 軸反転: 端末が逆さのため上下を補正 -- プレビューは画面いっぱいに拡大し,はみ出す部分はクロップする(`ClipRect` + スケール計算) +- 撮影者が見る向きへ向き変換する(`OrientedPreview`,X 反転=鏡像 + Y 反転=上下補正) +- 向き変換後,`PreviewRegion`(表示後座標 0.0–1.0)で口元を切り出して拡大表示する(`RegionCrop`) ### 撮影フロー(定量モード) diff --git "a/docs/04_SPEC/SPEC_04_\343\203\227\343\203\254\343\203\223\343\203\245\343\203\274\345\220\221\343\201\215\343\201\250\343\202\257\343\203\255\343\203\203\343\203\227.md" "b/docs/04_SPEC/SPEC_04_\343\203\227\343\203\254\343\203\223\343\203\245\343\203\274\345\220\221\343\201\215\343\201\250\343\202\257\343\203\255\343\203\203\343\203\227.md" new file mode 100644 index 0000000..8730cc5 --- /dev/null +++ "b/docs/04_SPEC/SPEC_04_\343\203\227\343\203\254\343\203\223\343\203\245\343\203\274\345\220\221\343\201\215\343\201\250\343\202\257\343\203\255\343\203\203\343\203\227.md" @@ -0,0 +1,146 @@ +# プレビュー向きとクロップ仕様書 (Preview Orientation & Crop Specification) + +本ドキュメントでは,SmTIAS-Capture のプレビュー表示における **向き変換(反転)** と +**クロップ拡大** の仕組み,座標系の定義,および変更時の確認手順を定義する. + +- 画面・UI は [SPEC_01 画面機能仕様書](SPEC_01_画面機能仕様書.md) を参照 +- カメラ制御・撮影フロー・保存ファイルは [SPEC_02 カメラ撮影仕様書](SPEC_02_カメラ撮影仕様書.md) を参照 +- 本書はプレビュー表示の向き・クロップについて SPEC_02「プレビュー表示」の記述を**詳細化・優先**する + +> **重要:撮影画像(DNG)には影響しない.** 本書が扱う向き変換・クロップは +> すべて**プレビュー表示のみ**の処理である.保存される DNG は別経路(Camera2)で取得する. + +--- + +## 1. 背景:なぜ向き変換が必要か + +スマホを**画面が下向き**になるようにアタッチメントへ載せて口元を撮影する運用のため, +カメラの生プレビューをそのまま表示すると,撮影者から見て上下・左右が反転している. +そこで「撮影者が自然に見える向き」に整える変換を表示時に適用する. + +正しい表示状態(撮影者が見る向き)の特徴: + +- 口元が中央に拡大表示される +- 上部のアタッチメントラベル「SmTIAS 001」が**鏡像+上下反転**で写る(=向き変換が効いている証拠) +- カラーチャートが画面右,位置合わせマーカーが四隅 + +> **基準スクリーンショットについて:** 実機の基準画像は被写体(顔・口元)の +> 個人情報を含むため Git にはコミットせず,`.gitignore` 除外の `local_reference/` +> にローカル保管する.確認時はその画像と見比べる(§5). + +--- + +## 2. 座標系の定義(用語の統一) + +本プロジェクトでは以下の2つの座標系を区別する.コード内コメントもこの用語に統一する. + +| 用語 | 意味 | +| --- | --- | +| **生フレーム座標** | カメラから来たままの向き(向き変換適用前).`OrientedPreview` の入力. | +| **表示後座標** | 向き変換を適用し,撮影者が見る向きにした後の座標(正規化 0.0–1.0).`PreviewRegion` / `RegionCrop` が扱う座標系. | + +**`PreviewRegion` のクロップ座標は必ず「表示後座標」で定義する.** +(向き変換の後に切り出すため.生フレーム座標ではない.) + +--- + +## 3. パイプライン全体図 + +プレビューには **ライブ系(描画)** と **スナップショット系(保存中表示)** の2系統があり, +**いずれも最終的に同じ `RegionCrop`(同じ `region`)でクロップ**して表示を一致させる. + +``` + ┌─ ライブプレビュー系 (Flutter 描画) ───────────┐ + │ AspectRatio(h/w で横→縦補正) │ +生カメラフレーム │ OrientedPreview(向き変換 = 180°回転) │ +(生フレーム座標) ────┤ oriented_preview.dart │── RegionCrop(region) + │ ※ camera_preview.dart │ 表示後座標で + └──────────────────────────────────────────────┘ クロップ&拡大 + ┌─ スナップショット系 (native 変換) ────────────┐ ▲ +最新フレーム │ convertYuvToJpeg(rotation:90, mirror:true) │ │ 同じ region・ +(_latestFrame) ────┤ camera_provider.dart │── CroppedSnapshot + │ = 向き処理済みの JPEG(表示後座標) │ → RegionCrop(region) + └──────────────────────────────────────────────┘──────┘ +``` + +### 向き変換が2系統に分かれている点(最重要) + +**同じ「撮影者が見る向き」を作るのに,2箇所で別々の手段を使っている.** + +| 系統 | 向き処理 | 実装箇所 | +| --- | --- | --- | +| ライブ(Flutter 描画) | `Matrix4.diagonal3Values(-1, -1, 1)`(X 反転+Y 反転=180°回転) | `lib/widgets/oriented_preview.dart`(`kDisplayFlip`) | +| スナップショット(native) | `rotation:90 + mirror:true`(Kotlin 側で回転・ミラー) | `lib/providers/camera_provider.dart`(`convertYuvToJpeg` 呼び出し) | + +> **向きを変更したいときは,この2箇所を整合させる必要がある.** +> 片方だけ変えるとライブ表示と保存スナップショットがズレる. +> 変更後は必ず §5 の確認手順を実施すること. + +--- + +## 4. クロップ拡大の仕組み(RegionCrop) + +`lib/widgets/region_crop.dart` が表示後座標 0.0–1.0 の矩形 `PreviewRegion` を受け取り, +以下を行う(ライブ・スナップショット共通). + +1. ROI(関心領域)の中心を画面中心へ平行移動 +2. ROI 全体が画面に収まる `contain` ズーム(縦横同一スケールで歪み防止) +3. `ClipRect` ではみ出しを切り取る + +クロップ領域は `assets/config/preview_region.json` から読み込み, +読み込み失敗・不正値の場合は `PreviewRegion.full`(全画面・クロップなし)にフォールバックする +(`lib/services/preview_region_service.dart`). +領域の調整には `scripts/preview_region_picker.html` を用いる. + +--- + +## 5. 変更時の確認手順(必須) + +向き変換・クロップに関わる変更を行ったら,**2段階**で確認する. + +### 段階1:自動チェック(リファクタ前後で見た目が変わっていないか) + +``` +flutter test test/widgets/preview_orientation_golden_test.dart +``` + +- 合成テストパターン(上=赤/下=青/左=緑/右=黄+左上マーカー円)を + `OrientedPreview`・`RegionCrop` に流し,正解ゴールデン画像と照合する +- 反転や座標を取り違えると色帯・円の位置がズレて**即失敗**する +- これは「変換を壊していないか」の高速チェックであり,実機は不要 + +> **意図的に向きを変更した場合**は,ゴールデンが古い正解のままなので失敗する. +> その場合のみ `flutter test --update-goldens` で正解を更新し, +> 更新後のゴールデン画像が意図通りかを目視確認してからコミットする. + +### 段階2:実機での最終確認(向きが本当に正しいか) + +- 実機で口元を撮影し,**画面全体のスクリーンショット**を撮る +- ローカル保管の基準画像(`local_reference/` 配下,個人情報のため非コミット) + と並べて目視で一致を確認する +- 確認観点: + - 口元が中央に拡大表示されているか + - 上部ラベル「SmTIAS 001」が鏡像+上下反転で写るか + - カラーチャートが右,マーカーが四隅にあるか + - 上下・左右が実際の口の向きと一致するか + +> 自動チェック(段階1)は「変わっていないこと」を守り, +> 実機確認(段階2)は「そもそも正しいこと」を守る.役割が異なるため両方行う. + +--- + +## 6. 関連ファイル + +| ファイル | 役割 | +| --- | --- | +| `lib/models/preview_region.dart` | クロップ領域(表示後座標)の不変モデル | +| `lib/services/preview_region_service.dart` | `preview_region.json` の読み込み・フォールバック | +| `lib/widgets/oriented_preview.dart` | 向き変換(ライブ系,`kDisplayFlip`) | +| `lib/widgets/region_crop.dart` | クロップ拡大(ライブ・スナップショット共通) | +| `lib/widgets/camera_preview.dart` | ライブプレビュー(向き変換+クロップ) | +| `lib/widgets/cropped_snapshot.dart` | 保存中スナップショット表示(クロップのみ) | +| `lib/providers/camera_provider.dart` | スナップショット系の向き処理(`rotation:90, mirror:true`) | +| `assets/config/preview_region.json` | クロップ領域の設定値 | +| `scripts/preview_region_picker.html` | クロップ領域の調整ツール | +| `test/widgets/preview_orientation_golden_test.dart` | 向き・クロップの回帰テスト(段階1) | +| `local_reference/`(非コミット) | 実機確認の基準スクリーンショット保管場所(段階2,個人情報のため Git 除外) | diff --git a/lib/models/preview_region.dart b/lib/models/preview_region.dart new file mode 100644 index 0000000..9e4346d --- /dev/null +++ b/lib/models/preview_region.dart @@ -0,0 +1,70 @@ +/// プレビュー表示領域を表す正規化矩形(不変クラス). +/// +/// 撮影画像(フル領域)に対する正規化座標(0.0–1.0)で, +/// CameraPreview をクロップ表示する領域を表す. +/// 撮影画像の出力には影響しない(プレビュー表示のみ). +class PreviewRegion { + /// 左端(0.0–1.0). + final double left; + + /// 上端(0.0–1.0). + final double top; + + /// 右端(0.0–1.0). + final double right; + + /// 下端(0.0–1.0). + final double bottom; + + const PreviewRegion({ + required this.left, + required this.top, + required this.right, + required this.bottom, + }); + + /// クロップなし(フル領域)を表す矩形. + /// + /// config 読み込み失敗時のフォールバックとして使用する. + static const PreviewRegion full = PreviewRegion( + left: 0.0, + top: 0.0, + right: 1.0, + bottom: 1.0, + ); + + /// クロップ領域の幅(正規化). + double get width => right - left; + + /// クロップ領域の高さ(正規化). + double get height => bottom - top; + + /// フル領域(クロップ不要)かどうか. + bool get isFull => left <= 0.0 && top <= 0.0 && right >= 1.0 && bottom >= 1.0; + + /// 各値が有効範囲内(0.0–1.0,left + left >= 0.0 && + left < right && + right <= 1.0 && + top >= 0.0 && + top < bottom && + bottom <= 1.0; + + /// JSON マップから [PreviewRegion] を生成する. + /// + /// 値が数値でない/欠落している場合はフォールバック値(フル領域)を用いる. + factory PreviewRegion.fromJson(Map json) { + double parse(Object? value, double fallback) { + if (value is num) return value.toDouble(); + return fallback; + } + + return PreviewRegion( + left: parse(json['left'], full.left), + top: parse(json['top'], full.top), + right: parse(json['right'], full.right), + bottom: parse(json['bottom'], full.bottom), + ); + } +} diff --git a/lib/providers/camera_provider.dart b/lib/providers/camera_provider.dart index f90f639..523e5c7 100644 --- a/lib/providers/camera_provider.dart +++ b/lib/providers/camera_provider.dart @@ -2,8 +2,10 @@ import 'package:flutter/foundation.dart'; import 'package:smtias_capture/models/preview_frame.dart'; +import 'package:smtias_capture/models/preview_region.dart'; import 'package:smtias_capture/services/file_service.dart'; import 'package:smtias_capture/services/permission_service.dart'; +import 'package:smtias_capture/services/preview_region_service.dart'; import 'package:smtias_capture/services/raw_capture_service.dart'; import 'package:smtias_capture/services/sound_service.dart'; @@ -13,9 +15,11 @@ final FileService _fileService = FileService(); final RawCaptureService _rawCaptureService = RawCaptureService(); final SoundService _soundService = SoundService(); + final PreviewRegionService _previewRegionService = PreviewRegionService(); CameraController? _controller; bool _isInitialized = false; + bool _initializing = false; String? _errorMessage; bool _permissionDenied = false; bool _permissionPermanentlyDenied = false; @@ -23,6 +27,7 @@ bool _isSaving = false; bool _imageStreamActive = false; PreviewFrame? _latestFrame; + PreviewRegion _previewRegion = PreviewRegion.full; CameraController? get controller => _controller; bool get isInitialized => _isInitialized; @@ -33,6 +38,9 @@ bool get isSaving => _isSaving; bool get isSoundEnabled => _soundService.isSoundEnabled; + /// プレビュー表示のクロップ領域(撮影画像には影響しない). + PreviewRegion get previewRegion => _previewRegion; + /// サウンド設定を切り替える. Future toggleSound() async { await _soundService.toggleSound(); @@ -45,8 +53,27 @@ } /// カメラを初期化する. + /// + /// 初期化処理が進行中の場合は即座に return して二重実行を防ぐ. + /// すでに初期化済み([isInitialized] が true かつ [controller] が非 null)の + /// 場合も何もしない. Future initialize() async { + // 二重実行防止: 初期化処理が進行中なら早期 return + if (_initializing) return; + // 既に初期化済みなら再初期化不要 + if (_isInitialized && _controller != null) return; + + _initializing = true; + try { + await _initializeInternal(); + } finally { + _initializing = false; + } + } + + Future _initializeInternal() async { await _soundService.init(); + _previewRegion = await _previewRegionService.load(); final granted = await _permissionService.requestCamera(); if (!granted) { diff --git a/lib/screens/capture_screen.dart b/lib/screens/capture_screen.dart index 8957ad0..c31cd10 100644 --- a/lib/screens/capture_screen.dart +++ b/lib/screens/capture_screen.dart @@ -6,6 +6,7 @@ import 'package:smtias_capture/providers/camera_provider.dart'; import 'package:smtias_capture/widgets/camera_preview.dart'; +import 'package:smtias_capture/widgets/cropped_snapshot.dart'; import 'package:smtias_capture/widgets/shutter_button.dart'; /// 撮影画面.カメラプレビューとシャッターボタンを表示する. @@ -162,19 +163,26 @@ return const Center(child: CircularProgressIndicator()); } + // ─── 通常撮影モード ─────────────────────────────────────────────────────── return Stack( children: [ - // 保存中はスナップショットを表示 + // 保存中はスナップショットを表示(ライブと同じ領域でクロップ) if (_snapshotBytes != null && (!cameraProvider.isInitialized || cameraProvider.isSaving)) Positioned.fill( - child: Image.memory(_snapshotBytes!, fit: BoxFit.cover), + child: CroppedSnapshot( + bytes: _snapshotBytes!, + region: cameraProvider.previewRegion, + ), ) // カメラプレビュー else if (cameraProvider.isInitialized && cameraProvider.controller != null) Positioned.fill( - child: CameraPreviewWidget(controller: cameraProvider.controller!), + child: CameraPreviewWidget( + controller: cameraProvider.controller!, + region: cameraProvider.previewRegion, + ), ), // 保存中インジケーター if (cameraProvider.isSaving) _buildSavingIndicator(), diff --git a/lib/services/preview_region_service.dart b/lib/services/preview_region_service.dart new file mode 100644 index 0000000..dfc1aca --- /dev/null +++ b/lib/services/preview_region_service.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart' show rootBundle; + +import 'package:smtias_capture/models/preview_region.dart'; + +/// `assets/config/preview_region.json` を読み込み, +/// プレビュークロップ領域([PreviewRegion])を提供するサービス. +/// +/// ロード優先順位: +/// 1. assets の `preview_region.json` が valid ならそれを使用 +/// 2. 読み込み失敗・不正値なら [PreviewRegion.full] にフォールバック +class PreviewRegionService { + static const _assetPath = 'assets/config/preview_region.json'; + + /// config を読み込んでプレビュー領域を返す. + /// + /// 優先順位: assets → [PreviewRegion.full]. + /// assets 読み込み失敗・不正値なら [PreviewRegion.full] にフォールバックする. + Future load() async { + // (1) assets ファイル + final assetsRegion = await _loadAssets(); + if (assetsRegion != null) { + return assetsRegion; + } + + // (2) フォールバック + return PreviewRegion.full; + } + + /// assets ファイルから読み込む.読み込み失敗・不正値なら null を返す. + Future _loadAssets() async { + try { + final raw = await rootBundle.loadString(_assetPath); + final decoded = json.decode(raw); + if (decoded is! Map) { + return null; + } + + final regionJson = decoded['preview_region']; + if (regionJson is! Map) { + return null; + } + + final region = PreviewRegion.fromJson(regionJson); + if (!region.isValid) { + debugPrint('PreviewRegionService: assets の領域値が不正のためフル表示にフォールバック'); + return null; + } + return region; + } catch (e) { + debugPrint('PreviewRegionService: assets 読み込み失敗 ($e)'); + return null; + } + } +} diff --git a/lib/services/raw_capture_service.dart b/lib/services/raw_capture_service.dart index 0e3b8a1..545858b 100644 --- a/lib/services/raw_capture_service.dart +++ b/lib/services/raw_capture_service.dart @@ -2,7 +2,9 @@ /// Camera2 API を使用してフル解像度の画像をキャプチャするサービス. class RawCaptureService { - static const _channel = MethodChannel('com.example.smtias_capture/raw_capture'); + static const _channel = MethodChannel( + 'com.example.smtias_capture/raw_capture', + ); /// フロントカメラからフル解像度の PNG バイト列をキャプチャする(ネイティブ変換). Future captureFullResolutionPng() async { diff --git a/lib/widgets/camera_preview.dart b/lib/widgets/camera_preview.dart index c0b04e7..45f4145 100644 --- a/lib/widgets/camera_preview.dart +++ b/lib/widgets/camera_preview.dart @@ -1,50 +1,51 @@ import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; -/// カメラのライブプレビューを全画面で表示するウィジェット. +import 'package:smtias_capture/models/preview_region.dart'; +import 'package:smtias_capture/widgets/oriented_preview.dart'; +import 'package:smtias_capture/widgets/region_crop.dart'; + +/// カメラのライブプレビューを表示するウィジェット. /// -/// フロントカメラの映像を鏡像(左右反転)で表示する. +/// [region] が指定された場合,**表示向きを適用したプレビューの画面座標** +/// (0.0–1.0)で矩形にクロップして拡大表示する(WYSIWYG / [RegionCrop]). +/// 撮影画像の出力には影響しない. class CameraPreviewWidget extends StatelessWidget { - const CameraPreviewWidget({super.key, required this.controller}); + const CameraPreviewWidget({ + super.key, + required this.controller, + this.region = PreviewRegion.full, + }); final CameraController controller; + /// プレビュー表示のクロップ領域(表示後の画面座標 0.0–1.0). + final PreviewRegion region; + @override Widget build(BuildContext context) { if (!controller.value.isInitialized) { return const Center(child: CircularProgressIndicator()); } - return LayoutBuilder( - builder: (context, constraints) { - final previewSize = controller.value.previewSize; - if (previewSize == null) { - return const Center(child: CircularProgressIndicator()); - } + final previewSize = controller.value.previewSize; + if (previewSize == null) { + return const Center(child: CircularProgressIndicator()); + } - // カメラのアスペクト比(プレビューは横長で返るので反転) - final cameraAspect = previewSize.height / previewSize.width; - final screenAspect = constraints.maxWidth / constraints.maxHeight; + // カメラのアスペクト比(プレビューは横長で返るので反転) + final cameraAspect = previewSize.height / previewSize.width; - // 画面いっぱいに表示するためのスケール - final scale = cameraAspect > screenAspect - ? constraints.maxHeight / (constraints.maxWidth / cameraAspect) - : constraints.maxWidth / (constraints.maxHeight * cameraAspect); - - return ClipRect( - child: Transform( - alignment: Alignment.center, - // X 軸を反転(鏡像),Y 軸を反転(上下逆転) - transform: Matrix4.diagonal3Values(-scale, -scale, 1.0), - child: Center( - child: AspectRatio( - aspectRatio: cameraAspect, - child: CameraPreview(controller), - ), - ), - ), - ); - }, + // 表示向きを適用したフルプレビュー(撮影者が見る向き=クロップ元). + final oriented = OrientedPreview( + child: Center( + child: AspectRatio( + aspectRatio: cameraAspect, + child: CameraPreview(controller), + ), + ), ); + + return RegionCrop(region: region, child: oriented); } } diff --git a/lib/widgets/cropped_snapshot.dart b/lib/widgets/cropped_snapshot.dart new file mode 100644 index 0000000..94b91c8 --- /dev/null +++ b/lib/widgets/cropped_snapshot.dart @@ -0,0 +1,28 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import 'package:smtias_capture/models/preview_region.dart'; +import 'package:smtias_capture/widgets/region_crop.dart'; + +/// 保存中に表示するスナップショット画像 [bytes] を [region] でクロップ表示する. +/// +/// スナップショット(preview.jpg)は native 側で既に向き処理(`rotation:90 + mirror`) +/// 済みのため,ここでは追加の反転は行わず,[RegionCrop] で同一座標系のクロップのみ +/// 適用してライブ表示と範囲を一致させる. +class CroppedSnapshot extends StatelessWidget { + const CroppedSnapshot({super.key, required this.bytes, required this.region}); + + final Uint8List bytes; + + /// クロップ領域(表示後の画面座標 0.0–1.0/ライブと共通). + final PreviewRegion region; + + @override + Widget build(BuildContext context) { + return RegionCrop( + region: region, + child: Image.memory(bytes, fit: BoxFit.contain, gaplessPlayback: true), + ); + } +} diff --git a/lib/widgets/oriented_preview.dart b/lib/widgets/oriented_preview.dart new file mode 100644 index 0000000..ee75b4c --- /dev/null +++ b/lib/widgets/oriented_preview.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +/// 表示の向き調整を行うかどうか(撮影者が見る向き). +/// +/// スマホを下向きにしてアタッチメントへ載せる運用のため, +/// 生プレビューを 180°回転(X 反転=鏡像 + Y 反転=上下反転)して +/// 「撮影者が見る向き」に整える. +/// +/// 向き変換の詳細・座標系は +/// [SPEC_04 プレビュー向きとクロップ](../../docs/04_SPEC/SPEC_04_プレビュー向きとクロップ.md) 参照. +const bool kDisplayFlip = true; + +/// [child](向き調整前のコンテンツ)に表示向き変換を適用するウィジェット. +/// +/// 「生フレーム座標」のコンテンツを,撮影者が見る「表示後座標」へ変換する. +/// この変換結果に対して [RegionCrop] が表示後座標 0.0–1.0 でクロップする. +/// +/// ライブプレビュー([child] = `CameraPreview`)とテスト([child] = 任意の画像)で +/// 共通利用できるよう,向き変換のみを担い [child] を差し替え可能にしている. +class OrientedPreview extends StatelessWidget { + const OrientedPreview({super.key, required this.child}); + + /// 向き調整前のコンテンツ(生フレーム座標). + final Widget child; + + @override + Widget build(BuildContext context) { + return Transform( + alignment: Alignment.center, + // kDisplayFlip: X 反転(鏡像)+ Y 反転(上下)= 180°回転. + // 下向き設置で撮影者が見る向きに合わせる.詳細は SPEC_04 参照. + transform: kDisplayFlip + ? Matrix4.diagonal3Values(-1.0, -1.0, 1.0) + : Matrix4.identity(), + child: child, + ); + } +} diff --git a/lib/widgets/region_crop.dart b/lib/widgets/region_crop.dart new file mode 100644 index 0000000..697bfef --- /dev/null +++ b/lib/widgets/region_crop.dart @@ -0,0 +1,55 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import 'package:smtias_capture/models/preview_region.dart'; + +/// 表示向き適用済みの全体コンテンツ [child] を [region](表示後の画面座標 0.0–1.0)で +/// 切り出して画面いっぱいに拡大表示する(WYSIWYG). +/// +/// ライブプレビュー([child] = CameraPreview)と保存中スナップショット +/// ([child] = 画像)で共通利用し,同じ領域・同じ拡大で表示を一致させる. +class RegionCrop extends StatelessWidget { + const RegionCrop({super.key, required this.region, required this.child}); + + /// クロップ領域(表示後の画面座標 0.0–1.0). + final PreviewRegion region; + + /// クロップ対象(表示向き適用済みで領域全体を埋めるコンテンツ). + final Widget child; + + @override + Widget build(BuildContext context) { + if (region.isFull) { + return child; + } + + return LayoutBuilder( + builder: (context, constraints) { + final double w = constraints.maxWidth; + final double h = constraints.maxHeight; + + final double roiCx = (region.left + region.right) / 2.0; + final double roiCy = (region.top + region.bottom) / 2.0; + + // ROI 中心を画面中心へ寄せる平行移動量(画面ピクセル) + final double dx = (0.5 - roiCx) * w; + final double dy = (0.5 - roiCy) * h; + + // ROI 全体を画面に収める contain ズーム(縦横同一スケールで歪み防止) + final double zoom = math.min(1.0 / region.width, 1.0 / region.height); + + return ClipRect( + child: Transform.scale( + scale: zoom, + alignment: Alignment.center, + child: Transform.translate( + offset: Offset(dx, dy), + child: SizedBox(width: w, height: h, child: child), + ), + ), + ); + }, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index e96d400..be7f607 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -62,6 +62,9 @@ # the material Icons class. uses-material-design: true + assets: + - assets/config/ + # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg diff --git a/scripts/preview_region_picker.html b/scripts/preview_region_picker.html new file mode 100644 index 0000000..af1968e --- /dev/null +++ b/scripts/preview_region_picker.html @@ -0,0 +1,461 @@ + + + + +Preview Region Picker — SmTIAS-Capture + + + +
+

Preview Region Picker

+ 参照画像でカメラプレビュー表示領域を矩形指定 → 正規化座標を JSON で保存 +
+ +
+ +
右パネルから画像を読み込んでください
+
+ + + + + + diff --git a/test/providers/gallery_provider_test.dart b/test/providers/gallery_provider_test.dart index db08dca..338abcc 100644 --- a/test/providers/gallery_provider_test.dart +++ b/test/providers/gallery_provider_test.dart @@ -224,7 +224,9 @@ late _GalleryProviderTestable provider; setUp(() async { - tempDir = await Directory.systemTemp.createTemp('smtias_capture_del_test_'); + tempDir = await Directory.systemTemp.createTemp( + 'smtias_capture_del_test_', + ); provider = _GalleryProviderTestable(tempDir.path); }); diff --git a/test/services/file_service_test.dart b/test/services/file_service_test.dart index 842e219..bfef402 100644 --- a/test/services/file_service_test.dart +++ b/test/services/file_service_test.dart @@ -335,7 +335,9 @@ late _FileServiceTestableQuantitative service; setUp(() async { - tempDir = await Directory.systemTemp.createTemp('smtias_capture_qm_test_'); + tempDir = await Directory.systemTemp.createTemp( + 'smtias_capture_qm_test_', + ); service = _FileServiceTestableQuantitative(tempDir.path); }); diff --git a/test/widgets/goldens/oriented_preview.png b/test/widgets/goldens/oriented_preview.png new file mode 100644 index 0000000..ff58684 --- /dev/null +++ b/test/widgets/goldens/oriented_preview.png Binary files differ diff --git a/test/widgets/goldens/preview_pipeline.png b/test/widgets/goldens/preview_pipeline.png new file mode 100644 index 0000000..57d726f --- /dev/null +++ b/test/widgets/goldens/preview_pipeline.png Binary files differ diff --git a/test/widgets/goldens/region_crop.png b/test/widgets/goldens/region_crop.png new file mode 100644 index 0000000..8c9a21f --- /dev/null +++ b/test/widgets/goldens/region_crop.png Binary files differ diff --git a/test/widgets/preview_orientation_golden_test.dart b/test/widgets/preview_orientation_golden_test.dart new file mode 100644 index 0000000..012f134 --- /dev/null +++ b/test/widgets/preview_orientation_golden_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:smtias_capture/models/preview_region.dart'; +import 'package:smtias_capture/widgets/oriented_preview.dart'; +import 'package:smtias_capture/widgets/region_crop.dart'; + +/// プレビューの「向き変換」「クロップ」の見た目をゴールデン画像で固定する回帰テスト. +/// +/// リファクタリング前に正解画像を生成・コミットし,リファクタリング後に +/// `flutter test` で再照合する(1px でも変われば失敗).これは「変換を壊して +/// いないか」の高速な自動チェックであり,「向きが本当に正しいか」の最終確認は +/// 実機の全体スクリーンショットと基準画像(local_reference/ 配下,個人情報のため +/// 非コミット)の目視比較で行う.詳細は SPEC_04 参照. +void main() { + // ゴールデンの基準サイズ(縦長プレビューを模す). + const size = Size(240, 360); + + // 中央寄りのクロップ領域(表示後座標 0.0–1.0). + const region = PreviewRegion(left: 0.25, top: 0.2, right: 0.75, bottom: 0.8); + + testWidgets('OrientedPreview: 向き変換が正解画像と一致する', (tester) async { + await _pump(tester, size, const OrientedPreview(child: _TestPattern())); + await expectLater( + find.byKey(_targetKey), + matchesGoldenFile('goldens/oriented_preview.png'), + ); + }); + + testWidgets('RegionCrop: クロップ拡大が正解画像と一致する', (tester) async { + await _pump( + tester, + size, + const RegionCrop(region: region, child: _TestPattern()), + ); + await expectLater( + find.byKey(_targetKey), + matchesGoldenFile('goldens/region_crop.png'), + ); + }); + + testWidgets('向き変換 + クロップ: パイプライン全体が正解画像と一致する', (tester) async { + await _pump( + tester, + size, + const RegionCrop( + region: region, + child: OrientedPreview(child: _TestPattern()), + ), + ); + await expectLater( + find.byKey(_targetKey), + matchesGoldenFile('goldens/preview_pipeline.png'), + ); + }); +} + +const _targetKey = ValueKey('golden-target'); + +/// [child] を固定サイズの [RepaintBoundary] で包んでレンダリングする. +Future _pump(WidgetTester tester, Size size, Widget child) async { + await tester.pumpWidget( + Center( + child: RepaintBoundary( + key: _targetKey, + child: SizedBox.fromSize(size: size, child: child), + ), + ), + ); +} + +/// 上下左右が一目で判別できる非対称テストパターン(向き・クロップ確認用の入力). +/// +/// 上=赤 / 下=青 / 左=緑 / 右=黄 の色帯と,左上寄りのマゼンタ円を描く. +/// 180°回転すれば色帯の上下・左右が入れ替わり,円が右下へ移動するため, +/// 反転や座標の取り違えがゴールデン差分として確実に現れる. +class _TestPattern extends StatelessWidget { + const _TestPattern(); + + @override + Widget build(BuildContext context) { + return CustomPaint(painter: _TestPatternPainter(), size: Size.infinite); + } +} + +class _TestPatternPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final w = size.width; + final h = size.height; + final t = (w < h ? w : h) * 0.15; + + void fill(Rect rect, Color color) { + canvas.drawRect(rect, Paint()..color = color); + } + + fill(Offset.zero & size, const Color(0xFFFFFFFF)); // 背景:白 + fill(Rect.fromLTWH(0, 0, w, t), const Color(0xFFE53935)); // 上:赤 + fill(Rect.fromLTWH(0, h - t, w, t), const Color(0xFF1E88E5)); // 下:青 + fill(Rect.fromLTWH(0, 0, t, h), const Color(0xFF43A047)); // 左:緑 + fill(Rect.fromLTWH(w - t, 0, t, h), const Color(0xFFFDD835)); // 右:黄 + + // 左上寄りのマーカー円(両軸反転とクロップ中心を判別する). + canvas.drawCircle( + Offset(w * 0.3, h * 0.3), + t * 0.6, + Paint()..color = const Color(0xFFD500F9), + ); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +}