From 017faa629ccd289cf0996d2707345f71eb167728 Mon Sep 17 00:00:00 2001 From: tact-software Date: Thu, 29 May 2025 17:57:39 +0900 Subject: [PATCH 01/16] =?UTF-8?q?=E3=83=89=E3=82=AD=E3=83=A5=E3=83=A1?= =?UTF-8?q?=E3=83=B3=E3=83=88=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DESIGN_TEMPLATE.md | 190 +++++++++ docs/IMPLEMENTATION_SUMMARY_TEMPLATE.md | 88 +++++ docs/prompt.md | 26 ++ docs/template/DESIGN_SAMPLE.md | 363 ++++++++++++++++++ .../template/IMPLEMENTATION_SUMMARY_SAMPLE.md | 161 ++++++++ 5 files changed, 828 insertions(+) create mode 100644 docs/DESIGN_TEMPLATE.md create mode 100644 docs/IMPLEMENTATION_SUMMARY_TEMPLATE.md create mode 100644 docs/prompt.md create mode 100644 docs/template/DESIGN_SAMPLE.md create mode 100644 docs/template/IMPLEMENTATION_SUMMARY_SAMPLE.md diff --git a/docs/DESIGN_TEMPLATE.md b/docs/DESIGN_TEMPLATE.md new file mode 100644 index 0000000..0e69d60 --- /dev/null +++ b/docs/DESIGN_TEMPLATE.md @@ -0,0 +1,190 @@ +# [機能名] - 詳細設計書 + +## 1. 概要 + +### 1.1 目的 + + +### 1.2 背景・現状の課題 + + +### 1.3 スコープ + + +### 1.4 優先度 + + +## 2. ユーザーストーリー + +### 2.1 想定ユーザー + + +### 2.2 ユースケース + + +- シナリオ1: +- シナリオ2: +- シナリオ3: + +## 3. 機能要件 + +### 3.1 必須要件 + + +### 3.2 オプション要件 + + +### 3.3 非機能要件 + + +- パフォーマンス: +- スケーラビリティ: +- ユーザビリティ: +- アクセシビリティ: + +## 4. UI/UX設計 + +### 4.1 画面構成 + + +``` +[ASCII アートまたは図の説明] +``` + +### 4.2 インタラクション + + +### 4.3 レスポンシブ対応 + + +## 5. 技術設計 + +### 5.1 アーキテクチャ + + +### 5.2 データモデル + + +```typescript +// TypeScript インターフェース定義例 +interface ExampleData { + id: string; + // ... +} +``` + +### 5.3 API設計 + + +### 5.4 状態管理 + + +### 5.5 使用技術・ライブラリ + + +| ライブラリ | 用途 | 選定理由 | +|----------|------|---------| +| example-lib | チャート描画 | 軽量で高機能 | + +## 6. 実装計画 + +### 6.1 フェーズ分け + + +- **フェーズ1**: 基本機能(X週間) + - [ ] タスク1 + - [ ] タスク2 + +- **フェーズ2**: 拡張機能(Y週間) + - [ ] タスク3 + - [ ] タスク4 + +### 6.2 マイルストーン + + +## 7. テスト計画 + +### 7.1 ユニットテスト + + +### 7.2 統合テスト + + +### 7.3 E2Eテスト + + +### 7.4 パフォーマンステスト + + +## 8. リスクと対策 + +### 8.1 技術的リスク + + +| リスク | 影響度 | 発生確率 | 対策 | +|-------|--------|---------|------| +| 例: パフォーマンス劣化 | 高 | 中 | 仮想化実装 | + +### 8.2 スケジュールリスク + + +## 9. セキュリティ考慮事項 + +### 9.1 データ保護 + + +### 9.2 アクセス制御 + + +## 10. 国際化(i18n)対応 + +### 10.1 翻訳対象 + + +### 10.2 ローカライゼーション + + +## 11. ドキュメント計画 + +### 11.1 ユーザードキュメント + + +### 11.2 開発者ドキュメント + + +## 12. 関連情報 + +### 12.1 参考資料 + + +- [リンク1](URL) +- [リンク2](URL) + +### 12.2 関連Issue/PR + + +- Issue #XX: +- PR #YY: + +## 13. 承認・レビュー + +### 13.1 レビュアー + + +- [ ] 技術リード: @username +- [ ] UIデザイナー: @username +- [ ] プロダクトオーナー: @username + +### 13.2 承認履歴 + + +| 日付 | レビュアー | ステータス | コメント | +|------|-----------|-----------|---------| +| YYYY-MM-DD | @username | 承認 | - | + +--- + +**注意事項**: +- このテンプレートは必要に応じて項目を追加・削除してください +- 小規模な機能の場合は、一部のセクションを省略可能です +- 設計書は実装前にレビューを受けてください \ No newline at end of file diff --git a/docs/IMPLEMENTATION_SUMMARY_TEMPLATE.md b/docs/IMPLEMENTATION_SUMMARY_TEMPLATE.md new file mode 100644 index 0000000..126529e --- /dev/null +++ b/docs/IMPLEMENTATION_SUMMARY_TEMPLATE.md @@ -0,0 +1,88 @@ +# 機能: [機能名] + +## 概要 + +[この機能の概要を2-3文で説明] + +## 実装期間 + +- ブランチ: `feature/[branch-name]` +- バージョン: v[X.X.X] +- コミット数: [主要コミット数] + +## 主要機能 + +### 1. [主要機能1] + +[機能の説明] + +#### 機能 + +- [機能詳細1] +- [機能詳細2] +- [機能詳細3] + +#### 技術詳細 + +- **[技術項目1]**: [詳細説明] +- **[技術項目2]**: [詳細説明] + +### 2. [主要機能2] + +[機能の説明] + +#### 機能 + +- [機能詳細1] +- [機能詳細2] + +#### 改善点 + +- [改善点1] +- [改善点2] + +## ファイル変更 + +### 新規ファイル + +- `path/to/file1` - [ファイルの説明] +- `path/to/file2` - [ファイルの説明] +- `path/to/file3` - [ファイルの説明] + +### 変更されたファイル + +主な変更は以下のファイルに加えられました: + +- `existing/file1.ts` - [変更内容の概要] +- `existing/file2.tsx` - [変更内容の概要] +- `existing/file3.rs` - [変更内容の概要] + +## バグ修正 + +1. **[バグの概要]** + - [問題の説明と解決方法] + +2. **[バグの概要]** + - [問題の説明と解決方法] + +## テスト方法 + +この機能は以下の方法でテストできます: + +1. [テスト方法1] +2. [テスト方法2] +3. [テスト方法3] + +## 今後の改善点 + +1. [将来の改善案1] +2. [将来の改善案2] +3. [将来の改善案3] + +## 依存関係 + +新しい外部依存関係は追加されていません。既存のライブラリを使用: + +- [ライブラリ1](用途) +- [ライブラリ2](用途) +- [ライブラリ3](用途) \ No newline at end of file diff --git a/docs/prompt.md b/docs/prompt.md new file mode 100644 index 0000000..1c7669a --- /dev/null +++ b/docs/prompt.md @@ -0,0 +1,26 @@ +# AIエージェントプロンプトサンプル + +## プロンプトの目的 + +## 例 + +### 機能開発開始時 + +ISSUEから開発仕様書を作成する。 + +``` +ghコマンドで、ISSUE#{issue_number}の内容を取得し、開発仕様書を作成してください。 +開発仕様書は、docs/DESIGN_TEMPLATE.mdを参考にしてください。 +サンプルとして、docs/template/DESIGN_SAMPLE.mdを参照してください。 +``` + +### 機能開発完了時 + +開発内容をドキュメントにまとめる。 + +``` +developブランチとの差分をgitコマンドで取得し、本ブランチの変更内容を理解してください。 +変更内容をdocs/IMPLEMENTATION_SUMMARY_TEMPLATE.mdを参考にして、docs/dev/にブランチ名を付けてドキュメントを作成してください。 +ドキュメントのタイトルは、ブランチ名を使用してください。 +その後、その要点をまとめて、CHANGELOG.mdを更新してください。 +``` diff --git a/docs/template/DESIGN_SAMPLE.md b/docs/template/DESIGN_SAMPLE.md new file mode 100644 index 0000000..7ace59c --- /dev/null +++ b/docs/template/DESIGN_SAMPLE.md @@ -0,0 +1,363 @@ +# アノテーションサイズ分布ヒストグラム - 詳細設計書 + +## 1. 概要 + +### 1.1 目的 + +データセット内のオブジェクトサイズの分布を視覚的に理解し、モデル設計とデータ拡張戦略の最適化を支援する。 + +### 1.2 背景・現状の課題 + +- アノテーションサイズ分布を確認するには外部ツール(Python、Excel等)へのエクスポートが必要 +- データセットの不均衡(小さい/大きいオブジェクトの偏り)の把握が困難 +- カテゴリ別のサイズ分布比較ができない +- リアルタイムでのインタラクティブな分析が不可能 + +### 1.3 スコープ + +**対象範囲**: +- バウンディングボックスのサイズ分布可視化 +- カテゴリ別比較機能 +- インタラクティブなフィルタリング +- 統計情報の表示とエクスポート + +**対象外**: +- セグメンテーションマスクの面積計算 +- 3D アノテーションのサイズ分析 +- 時系列でのサイズ変化追跡 + +### 1.4 優先度 + +Medium - Nice to have + +## 2. ユーザーストーリー + +### 2.1 想定ユーザー + +- データサイエンティスト +- 機械学習エンジニア +- データアノテーター/キュレーター + +### 2.2 ユースケース + +- **シナリオ1**: データセットの品質評価 + - ユーザーは新しいデータセットを受け取った + - オブジェクトサイズの分布を確認して、モデル設計の参考にしたい + - 小さすぎる/大きすぎるオブジェクトの割合を把握したい + +- **シナリオ2**: データ拡張戦略の決定 + - 特定のサイズ範囲のオブジェクトが少ないことを発見 + - 該当するアノテーションを特定して、重点的に拡張したい + +- **シナリオ3**: カテゴリ間の比較分析 + - カテゴリごとのサイズ特性を比較 + - 特定カテゴリのサイズ異常を検出 + +## 3. 機能要件 + +### 3.1 必須要件 + +- **ヒストグラム表示** + - 幅、高さ、面積、アスペクト比の4種類の分布 + - ビン数の自動/手動設定(5〜100) + - リニア/ログスケールの切り替え + +- **インタラクティブ機能** + - ホバーでの詳細情報表示(ビン範囲、カウント、割合) + - クリックで該当アノテーションをハイライト + +- **カテゴリ管理** + - カテゴリ別の色分け表示 + - 特定カテゴリの表示/非表示切り替え + +### 3.2 オプション要件 + +- **高度なフィルタリング** + - 複数の条件を組み合わせたフィルタリング + - フィルタ条件の保存/読み込み + +- **比較モード** + - 複数データセットの分布比較 + - 時系列での変化追跡 + +### 3.3 非機能要件 + +- **パフォーマンス**: 10,000アノテーションで初期描画3秒以内 +- **スケーラビリティ**: 100,000アノテーションまで対応 +- **ユーザビリティ**: 3クリック以内で主要機能にアクセス +- **アクセシビリティ**: WCAG 2.1 AA準拠 + +## 4. UI/UX設計 + +### 4.1 画面構成 + +```text +┌─────────────────────────────────────────────┐ +│ アノテーションサイズ分布 │ +├─────────────────────────────────────────────┤ +│ [分布タイプ▼] [ビン数: 20 ▼] [スケール▼] │ +│ [エクスポート] │ +├─────────────────────────────────────────────┤ +│ │ +│ ▁▂▃▄▅▆▇█▇▆▅▄▃▂▁ │ +│ ヒストグラム表示エリア │ +│ │ +├─────────────────────────────────────────────┤ +│ カテゴリフィルタ: │ +│ ☑ 全て ☐ person ☐ car ☐ bicycle ... │ +├─────────────────────────────────────────────┤ +│ サイズ範囲: [====|========|====] │ +│ Min: 50px Max: 500px │ +├─────────────────────────────────────────────┤ +│ 統計情報: │ +│ 平均: 234px | 中央値: 180px | σ: 89px │ +│ 最小: 10px | 最大: 1200px │ +└─────────────────────────────────────────────┘ +``` + +### 4.2 インタラクション + +- **ホバー時**: ツールチップで詳細表示 +- **クリック時**: 対応するアノテーションを画像ビューアでハイライト +- **ドラッグ時**: 範囲選択してズーム +- **右クリック**: コンテキストメニュー(エクスポート、リセット等) + +### 4.3 レスポンシブ対応 + +- **デスクトップ** (1200px+): フル機能 +- **タブレット** (768-1199px): サイドパネル折りたたみ +- **モバイル** (< 768px): 縦スクロール、簡略表示 + +## 5. 技術設計 + +### 5.1 アーキテクチャ + +```text +┌─────────────────┐ ┌─────────────────┐ +│ HistogramPanel │────▶│ useHistogramStore│ +└─────────────────┘ └─────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ HistogramChart │ │ HistogramUtils │ +└─────────────────┘ └─────────────────┘ +``` + +### 5.2 データモデル + +```typescript +interface HistogramBin { + range: [number, number]; + count: number; + categoryBreakdown: Map; + annotationIds: number[]; +} + +interface HistogramData { + type: 'width' | 'height' | 'area' | 'aspectRatio'; + bins: HistogramBin[]; + statistics: HistogramStatistics; +} + +interface HistogramStatistics { + mean: number; + median: number; + std: number; + min: number; + max: number; + q1: number; + q3: number; + total: number; +} + +interface HistogramSettings { + binCount: number; + scale: 'linear' | 'log'; + selectedCategories: Set; + sizeRange: [number, number] | null; +} +``` + +### 5.3 API設計 + +内部APIのみ(Tauriコマンドは不要) + +### 5.4 状態管理 + +```typescript +interface HistogramStore { + // 設定 + settings: HistogramSettings; + + // 計算結果 + histogramData: HistogramData | null; + highlightedAnnotations: Set; + + // アクション + setHistogramType: (type: HistogramType) => void; + setBinCount: (count: number) => void; + setScale: (scale: 'linear' | 'log') => void; + toggleCategory: (categoryId: number) => void; + setSizeRange: (range: [number, number] | null) => void; + highlightBin: (binIndex: number) => void; + exportData: (format: 'csv' | 'json') => void; +} +``` + +### 5.5 使用技術・ライブラリ + +| ライブラリ | 用途 | 選定理由 | +|-----------|------|----------| +| Recharts | チャート描画 | React対応、軽量、カスタマイズ性高 | +| d3-scale | スケール計算 | 業界標準、ログスケール対応 | +| simple-statistics | 統計計算 | 軽量、必要十分な機能 | + +## 6. 実装計画 + +### 6.1 フェーズ分け + +- **フェーズ1**: 基本機能(2週間) + - [x] ヒストグラムコンポーネントの作成 + - [x] 基本的な分布タイプ(幅、高さ、面積)の実装 + - [x] Rechartsの統合 + - [ ] 統計値計算ロジック + +- **フェーズ2**: インタラクティブ機能(1週間) + - [ ] ホバーツールチップ + - [ ] クリックでアノテーションハイライト + - [ ] カテゴリ別表示切替 + +- **フェーズ3**: フィルタリング(1週間) + - [ ] サイズ範囲スライダー + - [ ] フィルタ適用ロジック + - [ ] UIフィードバック + +- **フェーズ4**: エクスポート(3日) + - [ ] CSVエクスポート + - [ ] 画像エクスポート + +- **フェーズ5**: 最適化・テスト(3日) + - [ ] パフォーマンステスト + - [ ] UIレスポンシブ対応 + - [ ] ドキュメント作成 + +### 6.2 マイルストーン + +- M1: 基本的なヒストグラム表示(2週間後) +- M2: インタラクティブ機能完成(3週間後) +- M3: 全機能実装完了(4週間後) +- M4: リリース準備完了(5週間後) + +## 7. テスト計画 + +### 7.1 ユニットテスト + +- ヒストグラム計算関数 +- 統計値計算関数 +- ビン分割ロジック +- フィルタリング処理 + +### 7.2 統合テスト + +- Zustandストアとの連携 +- 大規模データセット(10,000+ アノテーション) +- カテゴリ切替時の再計算 + +### 7.3 E2Eテスト + +- ユーザーシナリオ1: 初回表示からエクスポートまで +- ユーザーシナリオ2: フィルタリングと分析 +- ユーザーシナリオ3: カテゴリ比較 + +### 7.4 パフォーマンステスト + +- 100,000アノテーションでの描画時間 +- メモリ使用量の監視 +- 再計算時のレスポンス + +## 8. リスクと対策 + +### 8.1 技術的リスク + +| リスク | 影響度 | 発生確率 | 対策 | +|--------|---------|----------|------| +| 大規模データでの描画遅延 | 高 | 中 | 仮想化、Web Worker使用 | +| メモリ不足 | 高 | 低 | 段階的データ読み込み | +| ライブラリ依存 | 中 | 低 | 動的インポート | + +### 8.2 スケジュールリスク + +- Rechartsのカスタマイズが想定より複雑 → D3.jsへの切り替えも検討 +- パフォーマンス要件未達 → 機能を段階的にリリース + +## 9. セキュリティ考慮事項 + +### 9.1 データ保護 + +- ローカル処理のみ(データ送信なし) +- エクスポート時のファイル権限確認 + +### 9.2 アクセス制御 + +- 読み取り専用操作のみ +- ファイルシステムへの書き込みは明示的な操作時のみ + +## 10. 国際化(i18n)対応 + +### 10.1 翻訳対象 + +- UIラベル(分布タイプ、統計情報等) +- ツールチップメッセージ +- エラーメッセージ + +### 10.2 ローカライゼーション + +- 数値フォーマット(小数点、千位区切り) +- 単位表示(px、%) + +## 11. ドキュメント計画 + +### 11.1 ユーザードキュメント + +- 機能概要と使い方 +- ヒストグラムの読み方ガイド +- FAQ + +### 11.2 開発者ドキュメント + +- コンポーネントAPI +- カスタマイズ方法 +- パフォーマンスチューニング + +## 12. 関連情報 + +### 12.1 参考資料 + +- [Recharts Documentation](https://recharts.org/) +- [D3.js Histogram Example](https://d3js.org/d3-array/bin) +- [Simple Statistics](https://simplestatistics.org/) + +### 12.2 関連Issue/PR + +- Issue #6: [FEATURE] アノテーションサイズ分布ヒストグラム + +## 13. 承認・レビュー + +### 13.1 レビュアー + +- [ ] 技術リード: @tact-software +- [ ] UIデザイナー: TBD +- [ ] プロダクトオーナー: TBD + +### 13.2 承認履歴 + +| 日付 | レビュアー | ステータス | コメント | +|------|-----------|-----------|----------| +| 2025-05-29 | - | ドラフト | 初版作成 | + +--- + +**注意事項**: +- この設計書は Issue #6 のサンプル実装例です +- 実際の実装時には、チームでのレビューと調整が必要です +- パフォーマンス要件は実際のユーザー環境に応じて調整してください \ No newline at end of file diff --git a/docs/template/IMPLEMENTATION_SUMMARY_SAMPLE.md b/docs/template/IMPLEMENTATION_SUMMARY_SAMPLE.md new file mode 100644 index 0000000..dec40b6 --- /dev/null +++ b/docs/template/IMPLEMENTATION_SUMMARY_SAMPLE.md @@ -0,0 +1,161 @@ +# 機能: 正解データ差分可視化 + +## 概要 + +この機能は、COAV (COCO Annotation Viewer) に比較モード機能を追加し、2つのCOCOデータセット(例:正解データと予測結果)の視覚的な比較と評価指標の算出を可能にします。 + +## 実装期間 + +- ブランチ: `feature/ground-truth-diff-visualization` +- バージョン: v1.x.x +- コミット数: 5つの主要コミット + +## 主要機能 + +### 1. 比較モード + +2つのCOCO形式のデータセットを並べて視覚的に比較できる新しいモードです。 + +#### 機能 + +- 2つのCOCO JSONファイルの読み込みと比較 +- TP(真陽性)、FP(偽陽性)、FN(偽陰性)アノテーションの視覚的な区別 +- IoUベースのマッチングアルゴリズム +- データセット間で異なる画像IDのサポート +- カテゴリマッピング機能 + +#### 技術詳細 + +- **マッチングアルゴリズム**: IoU閾値を使用したグリーディアルゴリズム +- **IoU計算方法**: + - バウンディングボックスIoU: 正確な面積計算 + - ポリゴンIoU: グリッドベースの近似(100x100グリッド) +- **複数マッチングサポート**: アノテーションごとの最大マッチ数を設定可能(1からN) +- **役割の割り当て**: 読み込んだファイルに対してGT/Predの役割を柔軟に割り当て + +### 2. カテゴリマッピング + +2つのデータセット間で異なるカテゴリ体系をマッピングできます。 + +#### 機能 + +- カテゴリ名による自動マッピング +- ドラッグ&ドロップスタイルの手動マッピングUI +- 1対多のカテゴリマッピングサポート +- マッピング済み/未マッピングカテゴリの視覚的表示 + +#### 改善点 + +- useEffectの無限ループを修正 +- マッピングでのカテゴリ再利用を許可 +- 未使用カテゴリの表示を改善 + +### 3. 評価指標 + +物体検出の評価指標をリアルタイムで計算・表示します。 + +#### 算出される指標 + +- 真陽性(TP) +- 偽陽性(FP) +- 偽陰性(FN) +- Precision = TP / (TP + FP) +- Recall = TP / (TP + FN) +- F1スコア = 2 * (Precision * Recall) / (Precision + Recall) + +#### 表示場所 + +- 統計ダイアログ +- アノテーション詳細パネル(比較されたアノテーション選択時) + +### 4. 視覚的な強化 + +#### カラーコーディング + +- 正解データ: TP/FNは緑色系 +- 予測結果: TP/FPは青/赤色系 +- 設定で色をカスタマイズ可能 + +#### UI改善 + +- 長時間処理用のローディングオーバーレイ +- 比較中の進捗表示 +- ズームボタンがビューポート中心を基準にズーム +- 統計での単一画像データセットサポート + +### 5. サンプルデータ生成の強化 + +比較モードのテスト用にペアデータセットを作成できるようサンプルデータジェネレータを強化しました。 + +#### 新しいオプション + +- 設定可能なマッチ分布でペアJSONを生成 +- カテゴリ名の変更(名前に"2"を追加) +- 以下の比率を設定可能: + - 完全一致 + - 部分一致 + - 不一致(FN) + - 追加オブジェクト(FP) + +## ファイル変更 + +### 新規ファイル + +- `src/components/ComparisonDialog/` - 比較設定UI +- `src/components/LoadingOverlay/` - グローバルローディングインジケータ +- `src/types/diff.ts` - 比較用のTypeScript型定義 +- `src/utils/diff.ts` - コア比較ロジック +- `src/stores/useLoadingStore.ts` - ローディング状態管理 +- `docs/COMPARISON_MODE_EVALUATION.md` - 詳細ドキュメント + +### 変更されたファイル + +主な変更は以下のファイルに加えられました: + +- `useAnnotationStore.ts` - 比較状態管理を追加 +- `ImageViewer.tsx` - ズーム機能の改善 +- `AnnotationLayer.tsx` - TP/FP/FNの可視化 +- `StatisticsDialog.tsx` - 評価指標の表示 +- `sample_generator.rs` - ペアデータ生成 + +## バグ修正 + +1. **カテゴリマッピングリセット問題** + - マッピングリセットを引き起こすuseEffectの無限ループを修正 + - 自動初期化を防ぐための手動マッピングフラグを追加 + +2. **カテゴリ再利用の制限** + - 同じカテゴリを複数回使用する制限を削除 + - 未使用カテゴリの表示ロジックを改善 + +3. **単一画像データセットサポート** + - 単一画像データセットで比較指標が表示されない問題を修正 + - 単一画像データの場合、ビューモードを自動的に'current'に設定 + +4. **ズーム動作** + - ズームボタンがビューポート中心を基準にズームするよう修正 + +## テスト方法 + +この機能は以下の方法でテストできます: + +1. ペアJSONオプション付きのサンプルデータジェネレータ +2. 実際の正解データと予測データセット +3. 異なるカテゴリ命名スキーム + +## 今後の改善点 + +1. 比較結果のエクスポート +2. 比較履歴/セッション +3. より正確なポリゴンIoU計算 +4. 複数画像のバッチ比較 +5. 追加の評価指標(mAPなど) + +## 依存関係 + +新しい外部依存関係は追加されていません。既存のライブラリを使用: + +- React + TypeScript +- Zustand(状態管理) +- Konva.js(可視化) +- Tauri(バックエンド操作) \ No newline at end of file From fd0b6a2f14de4ccecacf06b4b3e00fc874398353 Mon Sep 17 00:00:00 2001 From: tact-software Date: Fri, 30 May 2025 06:54:06 +0900 Subject: [PATCH 02/16] =?UTF-8?q?=E3=83=A2=E3=83=BC=E3=83=80=E3=83=AB?= =?UTF-8?q?=E5=85=B1=E9=80=9A=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 18 +- src/components/CommonModal/CommonModal.css | 201 +++++ src/components/CommonModal/CommonModal.tsx | 136 ++++ src/components/CommonModal/index.tsx | 1 + .../ComparisonDialog/ComparisonDialog.tsx | 685 +++++++++--------- src/components/ControlPanel/ControlPanel.tsx | 18 +- .../ImageSelectionDialog.tsx | 100 ++- src/components/SampleGeneratorDialog.tsx | 561 +++++++------- .../SettingsModal/SettingsModal.tsx | 415 ++++++----- .../StatisticsDialog/StatisticsDialog.tsx | 423 ++++++----- src/hooks/useMenuEvents.ts | 8 +- 11 files changed, 1422 insertions(+), 1144 deletions(-) create mode 100644 src/components/CommonModal/CommonModal.css create mode 100644 src/components/CommonModal/CommonModal.tsx create mode 100644 src/components/CommonModal/index.tsx diff --git a/src/App.tsx b/src/App.tsx index b420c51..c458a9f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,11 +7,12 @@ import './styles/themes.css'; import ImageViewer from './components/ImageViewer'; import ControlPanel from './components/ControlPanel'; import InfoPanel from './components/InfoPanel'; +import AnnotationDetailPanel from './components/AnnotationDetailPanel'; import SampleGeneratorDialog from './components/SampleGeneratorDialog'; import StatisticsDialog from './components/StatisticsDialog'; -import AnnotationDetailPanel from './components/AnnotationDetailPanel'; import SettingsModal from './components/SettingsModal'; import ImageSelectionDialog from './components/ImageSelectionDialog'; +import { ComparisonDialog } from './components/ComparisonDialog'; import { FilesPanel } from './components/FilesPanel'; import { ToastContainer } from './components/Toast'; import { LoadingOverlay } from './components/LoadingOverlay'; @@ -59,6 +60,7 @@ function App() { const [showSampleGenerator, setShowSampleGenerator] = useState(false); const [showStatistics, setShowStatistics] = useState(false); const [showImageSelection, setShowImageSelection] = useState(false); + const [showComparisonDialog, setShowComparisonDialog] = useState(false); const [tempCocoData, setTempCocoData] = useState<{ data: COCOData; annotationDir: string; @@ -68,7 +70,7 @@ function App() { const renderTabContent = (tab: string) => { switch (tab) { case 'control': - return ; + return setShowComparisonDialog(true)} />; case 'info': return ; case 'detail': @@ -575,7 +577,8 @@ function App() { handleGenerateSample, handleExportAnnotations, handleShowStatistics, - openSettingsModal + openSettingsModal, + () => setShowComparisonDialog(true) ); // Handle drag and drop @@ -655,6 +658,10 @@ function App() { e.preventDefault(); if (cocoData) handleShowStatistics(); break; + case 'k': + e.preventDefault(); + if (cocoData) setShowComparisonDialog(true); + break; } } else if (e.key === 'Escape') { e.preventDefault(); @@ -885,6 +892,11 @@ function App() { }} /> + setShowComparisonDialog(false)} + /> + void; + + /** モーダルのタイトル */ + title: string; + + /** モーダル内に表示するコンテンツ */ + children: React.ReactNode; + + /** フッター部分のコンテンツ(オプション) */ + footer?: React.ReactNode; + + /** モーダルのサイズ */ + size?: 'sm' | 'md' | 'lg' | 'xl'; + + /** 背景にblur効果を適用するか */ + hasBlur?: boolean; + + /** 追加のCSSクラス名 */ + className?: string; + + /** ESCキーで閉じるかどうか */ + closeOnEsc?: boolean; + + /** オーバーレイクリックで閉じるかどうか */ + closeOnOverlay?: boolean; +} + +export const CommonModal: React.FC = ({ + isOpen, + onClose, + title, + children, + footer, + size = 'md', + hasBlur = false, + className = '', + closeOnEsc = true, + closeOnOverlay = true, +}) => { + const modalRef = useRef(null); + const previousActiveElement = useRef(null); + + // ESCキーでモーダルを閉じる + useEffect(() => { + if (!isOpen || !closeOnEsc) return; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isOpen, onClose, closeOnEsc]); + + // フォーカス管理 + useEffect(() => { + if (isOpen) { + // 現在のフォーカス要素を保存 + previousActiveElement.current = document.activeElement as HTMLElement; + + // モーダル内の最初のフォーカス可能要素にフォーカス + const focusableElements = modalRef.current?.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + if (focusableElements && focusableElements.length > 0) { + (focusableElements[0] as HTMLElement).focus(); + } + } else { + // モーダルを閉じたら元の要素にフォーカスを戻す + previousActiveElement.current?.focus(); + } + }, [isOpen]); + + // オーバーレイクリック処理 + const handleOverlayClick = (e: React.MouseEvent) => { + if (closeOnOverlay && e.target === e.currentTarget) { + onClose(); + } + }; + + // モーダルを表示しない場合 + if (!isOpen) return null; + + const modalContent = ( +
+
e.stopPropagation()} + > +
+ + +
+ +
{children}
+ + {footer &&
{footer}
} +
+
+ ); + + // React Portalを使用してbody直下にレンダリング + return createPortal(modalContent, document.body); +}; diff --git a/src/components/CommonModal/index.tsx b/src/components/CommonModal/index.tsx new file mode 100644 index 0000000..ed79164 --- /dev/null +++ b/src/components/CommonModal/index.tsx @@ -0,0 +1 @@ +export { CommonModal } from './CommonModal'; diff --git a/src/components/ComparisonDialog/ComparisonDialog.tsx b/src/components/ComparisonDialog/ComparisonDialog.tsx index c02a965..11e3d8b 100644 --- a/src/components/ComparisonDialog/ComparisonDialog.tsx +++ b/src/components/ComparisonDialog/ComparisonDialog.tsx @@ -6,6 +6,7 @@ import { useAnnotationStore } from '../../stores/useAnnotationStore'; import { useSettingsStore } from '../../stores/useSettingsStore'; import { toast } from '../../stores/useToastStore'; import { useLoadingStore } from '../../stores/useLoadingStore'; +import { CommonModal } from '../CommonModal'; import type { COCOData } from '../../types/coco'; import type { ComparisonSettings, DiffDisplaySettings } from '../../types/diff'; import './ComparisonDialog.css'; @@ -351,400 +352,380 @@ export const ComparisonDialog = ({ isOpen, onClose }: Props) => { return comparisonData.categories.filter((cat) => !usedCategoryIds.has(cat.id)); }; - if (!isOpen) return null; - return ( -
-
e.stopPropagation()}> -
-

{t('comparison.title')}

- + <> + + + + + } + > + {/* Current File Info */} +
+

{t('comparison.currentFileInfo')}

+
+ {cocoData && ( + <> +
+ {t('comparison.fileInfo', { + images: cocoData.images.length, + annotations: cocoData.annotations.length, + })} +
+ {currentImageId && ( +
+ {t('comparison.currentImage')}:{' '} + {cocoData.images.find((img) => img.id === currentImageId)?.file_name || + 'Unknown'}{' '} + (ID: {currentImageId}) +
+ )} + + )} +
-
- {/* Current File Info */} -
-

{t('comparison.currentFileInfo')}

-
- {cocoData && ( - <> -
- {t('comparison.fileInfo', { - images: cocoData.images.length, - annotations: cocoData.annotations.length, - })} -
- {currentImageId && ( -
- {t('comparison.currentImage')}:{' '} - {cocoData.images.find((img) => img.id === currentImageId)?.file_name || - 'Unknown'}{' '} - (ID: {currentImageId}) -
- )} - - )} -
+ {/* File Selection */} +
+

{t('comparison.fileSelection')}

+
+ + {comparisonData && ( +
+ {t('comparison.fileInfo', { + images: comparisonData.images.length, + annotations: comparisonData.annotations.length, + })} +
+ )}
+
- {/* File Selection */} + {/* Image Selection (for multiple images) */} + {comparisonData && comparisonData.images.length > 1 && (
-

{t('comparison.fileSelection')}

-
-
+ )} - {/* Image Selection (for multiple images) */} - {comparisonData && comparisonData.images.length > 1 && ( -
-

{t('comparison.imageSelection')}

-
- - {selectedImageId && ( -
- {t('comparison.selectedImageAnnotations')}:{' '} - { - comparisonData.annotations.filter((ann) => ann.image_id === selectedImageId) - .length - } -
- )} -
-
- )} + {/* Role Selection */} +
+

{t('comparison.roleSelection')}

+
+ + +
+
- {/* Role Selection */} + {/* Category Mapping */} + {cocoData && comparisonData && (
-

{t('comparison.roleSelection')}

-
- - +

{t('comparison.categoryMapping')}

+
+ {t('comparison.categoryMappingDescription')}
-
- - {/* Category Mapping */} - {cocoData && comparisonData && ( -
-

{t('comparison.categoryMapping')}

-
- {t('comparison.categoryMappingDescription')} +
+
+
{t('comparison.currentFile')}
+
{t('comparison.comparisonFile')}
-
-
-
{t('comparison.currentFile')}
-
{t('comparison.comparisonFile')}
-
- {/* 割り当て済みカテゴリ */} - {cocoData.categories.map((cat) => { - const mappedCategories = categoryMapping.get(cat.id) || []; - if (mappedCategories.length === 0) return null; + {/* 割り当て済みカテゴリ */} + {cocoData.categories.map((cat) => { + const mappedCategories = categoryMapping.get(cat.id) || []; + if (mappedCategories.length === 0) return null; - return ( -
-
- {cat.name} ({cat.id}) -
-
- {/* マッピングされたカテゴリのバッジ */} - {mappedCategories.map((predCatId) => { - const predCat = comparisonData.categories.find((c) => c.id === predCatId); - return ( -
- - {predCat?.name} - -
- ); - })} - - {/* Addバッジ */} - {getUnusedCategories().length > 0 && ( - - )} -
+ return ( +
+
+ {cat.name} ({cat.id})
- ); - })} - - {/* 未割当カテゴリの行追加 */} - {cocoData.categories - .filter( - (cat) => - !categoryMapping.has(cat.id) || categoryMapping.get(cat.id)?.length === 0 - ) - .map((cat) => ( -
-
- {cat.name} ({cat.id}) -
-
- {/* 追加ボタンのみ */} - {getUnusedCategories().length > 0 && ( - - )} -
+
+ {/* マッピングされたカテゴリのバッジ */} + {mappedCategories.map((predCatId) => { + const predCat = comparisonData.categories.find((c) => c.id === predCatId); + return ( +
+ + {predCat?.name} + +
+ ); + })} + + {/* Addバッジ */} + {getUnusedCategories().length > 0 && ( + + )}
- ))} - - {/* 比較ファイルの未使用カテゴリ */} - {getUnusedCategories().length > 0 && ( -
-
- {t('comparison.availableCategories')} +
+ ); + })} + + {/* 未割当カテゴリの行追加 */} + {cocoData.categories + .filter( + (cat) => !categoryMapping.has(cat.id) || categoryMapping.get(cat.id)?.length === 0 + ) + .map((cat) => ( +
+
+ {cat.name} ({cat.id})
- {getUnusedCategories().map((cat) => ( -
- - {cat.name} -
- ))} + {/* 追加ボタンのみ */} + {getUnusedCategories().length > 0 && ( + + )}
- )} -
-
- )} + ))} - {/* IoU Threshold */} -
-

{t('comparison.matchingSettings')}

-
- - setIouThreshold(parseFloat(e.target.value))} - /> -
-
- - { - const value = parseInt(e.target.value); - if (!isNaN(value) && value >= 1) { - setMaxMatchesPerAnnotation(value); - } - }} - /> -
-
- -
- - -
-
{t('comparison.iouMethodDescription')}
- {iouMethod === 'polygon' && ( -
- ⚠️ - {t('comparison.polygonIoUWarning')} + {/* 比較ファイルの未使用カテゴリ */} + {getUnusedCategories().length > 0 && ( +
+
+ {t('comparison.availableCategories')} +
+
+ {getUnusedCategories().map((cat) => ( +
+ + {cat.name} +
+ ))} +
)}
- - {/* Display Settings */} -
-

{t('comparison.displaySettings')}

-
-
- @@ -490,12 +492,6 @@ const ControlPanel: React.FC = () => {

{t('info.noImageLoaded')}

)} - - {/* Comparison Dialog */} - setIsComparisonDialogOpen(false)} - />
); }; diff --git a/src/components/ImageSelectionDialog/ImageSelectionDialog.tsx b/src/components/ImageSelectionDialog/ImageSelectionDialog.tsx index a4f3d63..0ebc4db 100644 --- a/src/components/ImageSelectionDialog/ImageSelectionDialog.tsx +++ b/src/components/ImageSelectionDialog/ImageSelectionDialog.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { COCOImage } from '../../types/coco'; +import { CommonModal } from '../CommonModal'; import './ImageSelectionDialog.css'; interface ImageSelectionDialogProps { @@ -45,72 +46,55 @@ const ImageSelectionDialog: React.FC = ({ } }; - if (!isOpen) return null; - return ( -
-
e.stopPropagation()}> -
-

{t('imageSelection.title')}

- -
- -
-

{t('imageSelection.description')}

- - {images.length > 0 ? ( -
- {images.map((image) => ( -
setSelectedImageId(image.id)} - > -
- setSelectedImageId(image.id)} - /> -
- -
- ))} -
- ) : ( -
{t('imageSelection.noImages')}
- )} -
- -
+ + + } + > +

{t('imageSelection.description')}

+ + {images.length > 0 ? ( +
+ {images.map((image) => ( +
setSelectedImageId(image.id)} + > +
+ setSelectedImageId(image.id)} + /> +
+ +
+ ))}
-
-
+ ) : ( +
{t('imageSelection.noImages')}
+ )} + ); }; diff --git a/src/components/SampleGeneratorDialog.tsx b/src/components/SampleGeneratorDialog.tsx index ba38e58..e8e6f33 100644 --- a/src/components/SampleGeneratorDialog.tsx +++ b/src/components/SampleGeneratorDialog.tsx @@ -4,6 +4,7 @@ import { save } from '@tauri-apps/plugin-dialog'; import { dirname } from '@tauri-apps/api/path'; import { useTranslation } from 'react-i18next'; import { useLoadingStore } from '../stores/useLoadingStore'; +import { CommonModal } from './CommonModal'; import './SampleGeneratorDialog.css'; interface SampleGeneratorDialogProps { @@ -174,306 +175,284 @@ const SampleGeneratorDialog: React.FC = ({ } }; - if (!isOpen) return null; - return ( -
-
e.stopPropagation()}> -
-

{t('sampleGenerator.title')}

- -
- -
-

{t('sampleGenerator.description')}

- -
- - setFilename(e.target.value || 'sample')} - placeholder={t('sampleGenerator.fileNamePlaceholder')} - /> -
- -
- - setWidth(parseInt(e.target.value) || 800)} - min="100" - max="4000" - /> -
- -
- - setHeight(parseInt(e.target.value) || 600)} - min="100" - max="4000" - /> -
- -
- - - setImageCount(Math.min(10, Math.max(1, parseInt(e.target.value) || 1))) - } - min="1" - max="10" - /> -
- -
- - - setClassCount(Math.min(10, Math.max(1, parseInt(e.target.value) || 4))) - } - min="1" - max="10" - /> -
- -
- - setAnnotationCount(Math.max(1, parseInt(e.target.value) || 10))} - min="1" - max="10000" - /> -
- -
- - setMaxObjectSize(Math.max(20, parseInt(e.target.value) || 80))} - min="20" - max={Math.min(width, height) / 2} - /> -
- -
- -
+ + + } + > +

{t('sampleGenerator.description')}

+ +
+ + setFilename(e.target.value || 'sample')} + placeholder={t('sampleGenerator.fileNamePlaceholder')} + /> +
-
- -
+
+ + setWidth(parseInt(e.target.value) || 800)} + min="100" + max="4000" + /> +
-
- -
+
+ + setHeight(parseInt(e.target.value) || 600)} + min="100" + max="4000" + /> +
-
- -
+
+ + setImageCount(Math.min(10, Math.max(1, parseInt(e.target.value) || 1)))} + min="1" + max="10" + /> +
+ +
+ + setClassCount(Math.min(10, Math.max(1, parseInt(e.target.value) || 4)))} + min="1" + max="10" + /> +
-
-
+ +
+ +
+ {t('sampleGenerator.changePairCategoryNamesDescription')} +
+
+ +
+

{t('sampleGenerator.distributionSettings')}

+
+ {t('sampleGenerator.distributionDescription')} +
+ +
+ + adjustRatios('perfect', parseInt(e.target.value))} + /> +
+ +
+ + adjustRatios('partial', parseInt(e.target.value))} + /> +
+ +
+ + adjustRatios('noMatch', parseInt(e.target.value))} + /> +
+ +
+ + adjustRatios('additional', parseInt(e.target.value))} + /> +
+ +
+ {t('sampleGenerator.total')}:{' '} + {perfectMatchRatio + partialMatchRatio + noMatchRatio + additionalRatio}% +
+
+ + )} +
+ +
+

{t('sampleGenerator.sampleInclude')}

+
    +
  • {t('sampleGenerator.randomShapes', { count: annotationCount })}
  • +
  • + {annotationCount === 1 + ? t('sampleGenerator.differentCategory', { count: classCount }) + : t('sampleGenerator.differentCategories', { count: classCount })} +
  • +
  • {t('sampleGenerator.objectsWithAspectRatio', { size: maxObjectSize })}
  • +
  • {t('sampleGenerator.availableShapes')}
  • +
  • {t('sampleGenerator.outputFiles', { name: filename })}
  • + {includePairJson &&
  • {t('sampleGenerator.pairJsonOutput', { name: filename })}
  • } +
-
+ ); }; diff --git a/src/components/SettingsModal/SettingsModal.tsx b/src/components/SettingsModal/SettingsModal.tsx index 36b9b53..9c8e82f 100644 --- a/src/components/SettingsModal/SettingsModal.tsx +++ b/src/components/SettingsModal/SettingsModal.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useSettingsStore, Language, Theme } from '../../stores'; import { ColorSettings } from '../ColorSettings'; +import { CommonModal } from '../CommonModal'; import './SettingsModal.css'; interface SettingsModalProps { @@ -58,8 +59,6 @@ const SettingsModal: React.FC = ({ isOpen, onClose }) => { }); }, [language, theme, display, panelLayout]); - if (!isOpen) return null; - const handleSave = () => { setLanguage(localSettings.language); setTheme(localSettings.theme); @@ -75,245 +74,237 @@ const SettingsModal: React.FC = ({ isOpen, onClose }) => { } }; - const handleBackdropClick = (e: React.MouseEvent) => { - if (e.target === e.currentTarget) { - onClose(); - } - }; - return ( -
-
-
-

{t('settings.title')}

- -
- -
- - - - -
- -
- {activeTab === 'general' && ( -
-
-
- - -
+
+ + +
+ + } + > +
+ + + + +
-
- - -
+
+ {activeTab === 'general' && ( +
+
+
+ +
-
- )} - {activeTab === 'display' && ( -
-
-
- {t('settings.showLabels')} - -
+ + +
+
+
+ )} -
- {t('settings.showBoundingBoxes')} - -
+ {activeTab === 'display' && ( +
+
+
+
+ {t('settings.showLabels')} +
-
-
- {t('settings.lineWidth')} - {localSettings.display.lineWidth}px -
-
-
-
+
+ {t('settings.showBoundingBoxes')} +
+ +
-
- )} - {activeTab === 'panel' && ( -
-
-
-

{t('settings.leftPanel')}

-
- {localSettings.panelLayout.leftPanelTabs.map((tab) => ( -
- {getTabName(tab)} - -
- ))} -
+
+
+ {t('settings.lineWidth')} + {localSettings.display.lineWidth}px +
+
+
+
+ + setLocalSettings({ + ...localSettings, + display: { + ...localSettings.display, + lineWidth: parseFloat(e.target.value), + }, + }) + } + />
+
+
+
+ )} -
-

{t('settings.rightPanel')}

-
- {localSettings.panelLayout.rightPanelTabs.map((tab) => ( -
- {getTabName(tab)} - -
- ))} -
+ {activeTab === 'panel' && ( +
+
+
+

{t('settings.leftPanel')}

+
+ {localSettings.panelLayout.leftPanelTabs.map((tab) => ( +
+ {getTabName(tab)} + +
+ ))}
-
- )} - {activeTab === 'colors' && ( -
- -
- )} -
+
+

{t('settings.rightPanel')}

+
+ {localSettings.panelLayout.rightPanelTabs.map((tab) => ( +
+ {getTabName(tab)} + +
+ ))} +
+
+
+
+ )} -
- -
- - -
-
+ {activeTab === 'colors' && ( +
+ +
+ )}
-
+ ); }; diff --git a/src/components/StatisticsDialog/StatisticsDialog.tsx b/src/components/StatisticsDialog/StatisticsDialog.tsx index 3e54695..3d77181 100644 --- a/src/components/StatisticsDialog/StatisticsDialog.tsx +++ b/src/components/StatisticsDialog/StatisticsDialog.tsx @@ -2,6 +2,7 @@ import React, { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAnnotationStore } from '../../stores'; import { calculateAnnotationStatistics } from '../../utils/statistics'; +import { CommonModal } from '../CommonModal'; import './StatisticsDialog.css'; interface StatisticsDialogProps { @@ -248,261 +249,251 @@ const StatisticsDialog: React.FC = ({ isOpen, onClose }) return text; }; - if (!isOpen) return null; - return ( -
-
e.stopPropagation()}> -
-

{t('statistics.title')}

- -
+ + + } + > + {datasetStats ? ( + <> + {/* Dataset Overview Section - Always at top */} +
+

{t('statistics.dataset')}

+
+
+
{datasetStats.totalImages}
+
{t('statistics.images')}
+
+
+
{datasetStats.totalAnnotations}
+
{t('statistics.annotations')}
+
+
+
{datasetStats.totalCategories}
+
{t('statistics.categories')}
+
+
+
+ {datasetStats.avgAnnotationsPerImage.toFixed(2)} +
+
{t('statistics.avgPerImage')}
+
+
+
+ + {/* View Mode Toggle for Annotation Statistics */} + {hasMultipleImages && statistics && ( +
+

{t('statistics.annotationStatistics')}

+ {/* Only show view mode toggle for datasets with multiple images */} + {hasMultipleImages && ( +
+ + +
+ )} +
+ )} -
- {datasetStats ? ( - <> - {/* Dataset Overview Section - Always at top */} -
-

{t('statistics.dataset')}

-
-
-
{datasetStats.totalImages}
-
{t('statistics.images')}
-
-
-
{datasetStats.totalAnnotations}
-
{t('statistics.annotations')}
+ {/* Category Distribution */} + {viewStats && viewStats.categoryDistribution.length > 0 && ( +
+

+ {t('statistics.categoryBreakdown')} + {hasMultipleImages && + ` - ${viewMode === 'current' ? t('statistics.currentImage') : t('statistics.allImages')}`} +

+
+ {viewStats.categoryDistribution.map((cat) => ( +
+
+ {cat.name} + {cat.count} +
+
+
+ {cat.percentage.toFixed(1)}% +
-
-
{datasetStats.totalCategories}
-
{t('statistics.categories')}
+ ))} +
+
+ )} + + {/* Annotation Statistics based on view mode */} + {statistics && ( +
+

+ {t('statistics.annotationStatistics')} + {hasMultipleImages && + ` - ${viewMode === 'current' ? t('infoPanel.currentImage') : t('infoPanel.allImages')}`} +

+
+
+
{statistics.totalAnnotations}
+
{t('statistics.annotations')}
+
+
+
{statistics.visibleAnnotations}
+
+ {t('info.visible')} {t('controls.annotations')}
-
-
- {datasetStats.avgAnnotationsPerImage.toFixed(2)} -
-
{t('statistics.avgPerImage')}
+
+
+
{statistics.selectedAnnotations}
+
+ {t('info.selected')} {t('controls.annotations')}
+
+
{statistics.coveragePercentage.toFixed(1)}%
+
{t('info.coverage')}
+
+
+ )} - {/* View Mode Toggle for Annotation Statistics */} - {hasMultipleImages && statistics && ( -
-

{t('statistics.annotationStatistics')}

- {/* Only show view mode toggle for datasets with multiple images */} - {hasMultipleImages && ( -
- - -
- )} + {/* Size Statistics */} + {viewStats && ( +
+

+ {t('statistics.annotationSizes')} + {hasMultipleImages && + ` - ${viewMode === 'current' ? t('statistics.currentImage') : t('statistics.allImages')}`} +

+
+
+
{viewStats.avgArea.toFixed(0)}
+
+ {t('statistics.average')} {t('detail.area')} +
- )} - - {/* Category Distribution */} - {viewStats && viewStats.categoryDistribution.length > 0 && ( -
-

- {t('statistics.categoryBreakdown')} - {hasMultipleImages && - ` - ${viewMode === 'current' ? t('statistics.currentImage') : t('statistics.allImages')}`} -

-
- {viewStats.categoryDistribution.map((cat) => ( -
-
- {cat.name} - {cat.count} -
-
-
- {cat.percentage.toFixed(1)}% -
-
- ))} +
+
{viewStats.avgWidth.toFixed(0)}
+
+ {t('statistics.average')} {t('detail.width')}
- )} +
+
{viewStats.avgHeight.toFixed(0)}
+
+ {t('statistics.average')} {t('detail.height')} +
+
+
+
+ )} - {/* Annotation Statistics based on view mode */} - {statistics && ( -
-

- {t('statistics.annotationStatistics')} - {hasMultipleImages && - ` - ${viewMode === 'current' ? t('infoPanel.currentImage') : t('infoPanel.allImages')}`} -

+ {/* Comparison Evaluation Metrics - Only show for current image view */} + {viewStats && viewMode === 'current' && ( +
+

{t('statistics.comparisonMetrics')}

+ {isComparing && currentImageComparisonMetrics ? ( + <>
-
{statistics.totalAnnotations}
-
{t('statistics.annotations')}
+
{currentImageComparisonMetrics.tp}
+
{t('statistics.truePositives')}
+
+
+
{currentImageComparisonMetrics.fp}
+
{t('statistics.falsePositives')}
-
{statistics.visibleAnnotations}
-
- {t('info.visible')} {t('controls.annotations')} +
{currentImageComparisonMetrics.fn}
+
{t('statistics.falseNegatives')}
+
+
+
+
+
+ {(currentImageComparisonMetrics.precision * 100).toFixed(1)}%
+
{t('statistics.precision')}
-
{statistics.selectedAnnotations}
-
- {t('info.selected')} {t('controls.annotations')} +
+ {(currentImageComparisonMetrics.recall * 100).toFixed(1)}%
+
{t('statistics.recall')}
- {statistics.coveragePercentage.toFixed(1)}% + {(currentImageComparisonMetrics.f1 * 100).toFixed(1)}%
-
{t('info.coverage')}
+
{t('statistics.f1Score')}
-
- )} - - {/* Size Statistics */} - {viewStats && ( -
-

- {t('statistics.annotationSizes')} - {hasMultipleImages && - ` - ${viewMode === 'current' ? t('statistics.currentImage') : t('statistics.allImages')}`} -

+ + ) : ( + <>
-
{viewStats.avgArea.toFixed(0)}
-
- {t('statistics.average')} {t('detail.area')} -
+
-
+
{t('statistics.truePositives')}
-
{viewStats.avgWidth.toFixed(0)}
-
- {t('statistics.average')} {t('detail.width')} -
+
-
+
{t('statistics.falsePositives')}
-
{viewStats.avgHeight.toFixed(0)}
-
- {t('statistics.average')} {t('detail.height')} -
+
-
+
{t('statistics.falseNegatives')}
-
- )} - - {/* Comparison Evaluation Metrics - Only show for current image view */} - {viewStats && viewMode === 'current' && ( -
-

{t('statistics.comparisonMetrics')}

- {isComparing && currentImageComparisonMetrics ? ( - <> -
-
-
{currentImageComparisonMetrics.tp}
-
{t('statistics.truePositives')}
-
-
-
{currentImageComparisonMetrics.fp}
-
{t('statistics.falsePositives')}
-
-
-
{currentImageComparisonMetrics.fn}
-
{t('statistics.falseNegatives')}
-
-
-
-
-
- {(currentImageComparisonMetrics.precision * 100).toFixed(1)}% -
-
{t('statistics.precision')}
-
-
-
- {(currentImageComparisonMetrics.recall * 100).toFixed(1)}% -
-
{t('statistics.recall')}
-
-
-
- {(currentImageComparisonMetrics.f1 * 100).toFixed(1)}% -
-
{t('statistics.f1Score')}
-
-
- - ) : ( - <> -
-
-
-
-
{t('statistics.truePositives')}
-
-
-
-
-
{t('statistics.falsePositives')}
-
-
-
-
-
{t('statistics.falseNegatives')}
-
-
-
-
-
-
-
{t('statistics.precision')}
-
-
-
-
-
{t('statistics.recall')}
-
-
-
-
-
{t('statistics.f1Score')}
-
-
-
- {t('statistics.comparisonModeOnly')} -
- - )} -
+
+
+
-
+
{t('statistics.precision')}
+
+
+
-
+
{t('statistics.recall')}
+
+
+
-
+
{t('statistics.f1Score')}
+
+
+
{t('statistics.comparisonModeOnly')}
+ )} - - ) : ( -
-

{t('info.noImageLoaded')}

)} + + ) : ( +
+

{t('info.noImageLoaded')}

- -
- - -
-
-
+ )} + ); }; diff --git a/src/hooks/useMenuEvents.ts b/src/hooks/useMenuEvents.ts index a73ab07..66b997d 100644 --- a/src/hooks/useMenuEvents.ts +++ b/src/hooks/useMenuEvents.ts @@ -8,7 +8,8 @@ export const useMenuEvents = ( onGenerateSample: () => void, onExportAnnotations: () => void, onShowStatistics: () => void, - onShowSettings: () => void + onShowSettings: () => void, + onShowComparison?: () => void ) => { const { zoomIn, zoomOut, resetView, fitToWindow } = useImageStore(); const { showAllCategories, hideAllCategories, clearCocoData } = useAnnotationStore(); @@ -40,6 +41,11 @@ export const useMenuEvents = ( await listen('menu-hide_all_categories', hideAllCategories), await listen('menu-clear_data', clearCocoData) ); + + // Add comparison dialog listener if handler provided + if (onShowComparison) { + unsubscribers.push(await listen('menu-compare', onShowComparison)); + } }; setupListeners(); From bc0373af96be9daba196e5c249017f2eea6bac01 Mon Sep 17 00:00:00 2001 From: tact-software Date: Fri, 30 May 2025 08:03:09 +0900 Subject: [PATCH 03/16] =?UTF-8?q?=E3=83=92=E3=82=B9=E3=83=88=E3=82=B0?= =?UTF-8?q?=E3=83=A9=E3=83=A0=E7=B5=B1=E8=A8=88=E6=83=85=E5=A0=B1=E6=A9=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bun.lock | 69 ++++- package.json | 1 + src-tauri/src/menu.rs | 7 + src/App.tsx | 16 +- .../HistogramChart/HistogramChart.css | 100 +++++++ .../HistogramChart/HistogramChart.tsx | 181 ++++++++++++ src/components/HistogramChart/index.tsx | 1 + .../HistogramPanel/HistogramPanel.css | 207 +++++++++++++ .../HistogramPanel/HistogramPanel.tsx | 260 +++++++++++++++++ src/components/HistogramPanel/index.tsx | 1 + src/hooks/useMenuEvents.ts | 10 +- src/i18n/locales/en.json | 27 ++ src/i18n/locales/ja.json | 27 ++ src/stores/index.ts | 2 + src/stores/useHistogramStore.ts | 157 ++++++++++ src/utils/histogram.ts | 275 ++++++++++++++++++ src/utils/index.ts | 1 + 17 files changed, 1339 insertions(+), 3 deletions(-) create mode 100644 src/components/HistogramChart/HistogramChart.css create mode 100644 src/components/HistogramChart/HistogramChart.tsx create mode 100644 src/components/HistogramChart/index.tsx create mode 100644 src/components/HistogramPanel/HistogramPanel.css create mode 100644 src/components/HistogramPanel/HistogramPanel.tsx create mode 100644 src/components/HistogramPanel/index.tsx create mode 100644 src/stores/useHistogramStore.ts create mode 100644 src/utils/histogram.ts diff --git a/bun.lock b/bun.lock index 432bd61..67905e4 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "react-dom": "^18.3.1", "react-i18next": "^15.5.2", "react-konva": "18", + "recharts": "^2.15.3", "zustand": "^5.0.5", }, "devDependencies": { @@ -290,6 +291,24 @@ "@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="], + "@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + "@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -402,6 +421,8 @@ "check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -418,6 +439,28 @@ "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + "data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="], "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], @@ -430,6 +473,8 @@ "decimal.js": ["decimal.js@10.5.0", "", {}, "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], @@ -444,6 +489,8 @@ "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], @@ -502,10 +549,14 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "expect-type": ["expect-type@1.2.1", "", {}, "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-equals": ["fast-equals@5.2.2", "", {}, "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw=="], + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], @@ -598,6 +649,8 @@ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], @@ -806,7 +859,7 @@ "react-i18next": ["react-i18next@15.5.2", "", { "dependencies": { "@babel/runtime": "^7.25.0", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.2.3", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-ePODyXgmZQAOYTbZXQn5rRsSBu3Gszo69jxW6aKmlSgxKAI1fOhDwSu6bT4EKHciWPKQ7v7lPrjeiadR6Gi+1A=="], - "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "react-konva": ["react-konva@18.2.10", "", { "dependencies": { "@types/react-reconciler": "^0.28.2", "its-fine": "^1.1.1", "react-reconciler": "~0.29.0", "scheduler": "^0.23.0" }, "peerDependencies": { "konva": "^8.0.1 || ^7.2.5 || ^9.0.0", "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-ohcX1BJINL43m4ynjZ24MxFI1syjBdrXhqVxYVDw2rKgr3yuS0x/6m1Y2Z4sl4T/gKhfreBx8KHisd0XC6OT1g=="], @@ -814,6 +867,14 @@ "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + "react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="], + + "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], + + "recharts": ["recharts@2.15.3", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-EdOPzTwcFSuqtvkDoaM5ws/Km1+WTAO2eizL7rqiG0V2UVhTnz0m7J2i0CjVPUCdEkZImaWvXLbZDS2H5t6GFQ=="], + + "recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="], + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], @@ -906,6 +967,8 @@ "test-exclude": ["test-exclude@7.0.1", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", "minimatch": "^9.0.4" } }, "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], @@ -952,6 +1015,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], + "vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="], "vite-node": ["vite-node@3.1.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.0", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-6enNwYnpyDo4hEgytbmc6mYWHXDHYEn0D1/rw4Q+tnHUGtKTJsn8T1YkX6Q18wI5LCrS8CTYlBaiCqxOy2kvUA=="], @@ -1040,6 +1105,8 @@ "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], diff --git a/package.json b/package.json index 4f2c7a7..4c8053d 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "react-dom": "^18.3.1", "react-i18next": "^15.5.2", "react-konva": "18", + "recharts": "^2.15.3", "zustand": "^5.0.5" }, "devDependencies": { diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 64839d8..1b93a76 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -106,6 +106,13 @@ pub fn create_menu(app: &tauri::AppHandle) -> Result, Box setShowComparisonDialog(true) + () => setShowComparisonDialog(true), + () => setShowHistogramDialog(true) ); // Handle drag and drop @@ -897,6 +901,16 @@ function App() { onClose={() => setShowComparisonDialog(false)} /> + setShowHistogramDialog(false)} + title={t('histogram.title')} + size="xl" + hasBlur={true} + > + + + void; + onBinHover?: (binIndex: number | null) => void; + highlightedBinIndex?: number | null; +} + +interface ChartDataPoint { + binIndex: number; + range: string; + count: number; + percentage: number; + categoryBreakdown: Array<{ categoryId: number; count: number }>; +} + +const CustomTooltip: React.FC> = ({ active, payload }) => { + const { t } = useTranslation(); + + if (!active || !payload || !payload[0]) { + return null; + } + + const data = payload[0].payload as ChartDataPoint; + + return ( +
+
+ {data.range} +
+
+
{t('histogram.count')}: {data.count}
+
{t('histogram.percentage')}: {data.percentage.toFixed(1)}%
+
+
+ ); +}; + +export const HistogramChart: React.FC = ({ + data, + onBinClick, + onBinHover, + highlightedBinIndex, +}) => { + const { t } = useTranslation(); + + // チャートデータを準備 + const chartData = useMemo(() => { + return data.bins.map((bin, index) => { + const rangeStr = formatRange(bin.range, data.type); + const percentage = (bin.count / data.statistics.total) * 100; + + return { + binIndex: index, + range: rangeStr, + count: bin.count, + percentage, + categoryBreakdown: Array.from(bin.categoryBreakdown.entries()).map(([categoryId, count]) => ({ + categoryId, + count, + })), + }; + }); + }, [data]); + + // Y軸のラベルフォーマット + const formatYAxis = (value: number) => { + if (value >= 1000) { + return `${(value / 1000).toFixed(1)}k`; + } + return value.toString(); + }; + + // X軸のラベルフォーマット + const formatXAxisTick = (value: string, index: number) => { + // ビンが多い場合は一部のラベルのみ表示 + if (data.bins.length > 20 && index % 2 !== 0) { + return ''; + } + return value; + }; + + // バーの色を決定 + const getBarColor = (index: number) => { + if (highlightedBinIndex === index) { + return 'var(--color-brand-primary-dark)'; + } + return 'var(--color-brand-primary)'; + }; + + return ( +
+ + onBinHover?.(null)} + > + + + + } + cursor={{ fill: 'var(--color-brand-primary)', opacity: 0.1 }} + /> + onBinClick?.(data.binIndex)} + onMouseEnter={(data: ChartDataPoint) => onBinHover?.(data.binIndex)} + > + {chartData.map((_, index) => ( + + ))} + + + + +
+ ); +}; + +// 範囲のフォーマット +function formatRange(range: [number, number], type: HistogramType): string { + const [start, end] = range; + + if (type === 'aspectRatio') { + return `${start.toFixed(2)}-${end.toFixed(2)}`; + } + + if (end >= 1000) { + return `${Math.round(start)}-${Math.round(end)}`; + } + + return `${Math.round(start)}-${Math.round(end)}`; +} + +// 値のフォーマット +function formatValue(value: number, type: HistogramType): string { + switch (type) { + case 'aspectRatio': + return value.toFixed(2); + case 'area': + case 'polygonArea': + if (value >= 1000000) { + return `${(value / 1000000).toFixed(1)}M`; + } + if (value >= 1000) { + return `${(value / 1000).toFixed(1)}k`; + } + return Math.round(value).toString(); + default: + return Math.round(value).toString(); + } +} \ No newline at end of file diff --git a/src/components/HistogramChart/index.tsx b/src/components/HistogramChart/index.tsx new file mode 100644 index 0000000..acdee57 --- /dev/null +++ b/src/components/HistogramChart/index.tsx @@ -0,0 +1 @@ +export { HistogramChart } from './HistogramChart'; \ No newline at end of file diff --git a/src/components/HistogramPanel/HistogramPanel.css b/src/components/HistogramPanel/HistogramPanel.css new file mode 100644 index 0000000..e93e050 --- /dev/null +++ b/src/components/HistogramPanel/HistogramPanel.css @@ -0,0 +1,207 @@ +/* HistogramPanel は CommonModal 内で使用されるため、背景色や padding は不要 */ + +/* Statistics Section - 統計情報ダイアログと同じスタイル */ +.statistics-section { + margin-bottom: var(--spacing-2xl); +} + +.statistics-section:last-child { + margin-bottom: 0; +} + +.statistics-section h3 { + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: var(--spacing-md); +} + +.histogram-controls { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--spacing-md); + padding: var(--spacing-lg); + background: var(--color-bg-secondary); + border-radius: var(--radius-md); + border: 1px solid var(--color-border-primary); +} + +.control-group { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.control-group label { + font-size: 0.875rem; + font-weight: 500; + color: var(--color-text-secondary); +} + +.select { + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: var(--color-bg-primary); + color: var(--color-text-primary); + font-size: 0.875rem; + transition: all 0.2s ease; +} + +.select:hover { + border-color: var(--color-brand-primary); +} + +.select:focus { + outline: none; + border-color: var(--color-brand-primary); + box-shadow: 0 0 0 3px rgba(var(--color-brand-primary-rgb), 0.1); +} + +.range-input { + width: 100%; + height: 4px; + border-radius: 2px; + background: var(--color-bg-tertiary); + outline: none; + -webkit-appearance: none; +} + +.range-input::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--color-brand-primary); + cursor: pointer; + transition: all 0.2s ease; +} + +.range-input::-webkit-slider-thumb:hover { + transform: scale(1.2); + box-shadow: 0 0 0 4px rgba(var(--color-brand-primary-rgb), 0.2); +} + +.range-input::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--color-brand-primary); + cursor: pointer; + border: none; + transition: all 0.2s ease; +} + +.range-input::-moz-range-thumb:hover { + transform: scale(1.2); + box-shadow: 0 0 0 4px rgba(var(--color-brand-primary-rgb), 0.2); +} + +.radio-group { + display: flex; + gap: var(--spacing-md); +} + +.radio-group label { + display: flex; + align-items: center; + gap: var(--spacing-xs); + font-size: 0.875rem; + color: var(--color-text-primary); + cursor: pointer; +} + +.radio-group input[type="radio"] { + cursor: pointer; +} + +.category-filter { + margin-bottom: var(--spacing-xl); +} + +.category-filter h4 { + margin: 0 0 var(--spacing-md) 0; + font-size: 1rem; + font-weight: 600; + color: var(--color-text-primary); +} + +.category-chips { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm); +} + +.chip { + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-full); + background: var(--color-bg-secondary); + color: var(--color-text-secondary); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.chip:hover { + border-color: var(--color-brand-primary); + color: var(--color-brand-primary); +} + +.chip--active { + background: var(--color-brand-primary); + color: white; + border-color: var(--color-brand-primary); +} + +.chip--active:hover { + background: var(--color-brand-primary-dark); + border-color: var(--color-brand-primary-dark); +} + +/* Empty State - 統計情報ダイアログと同じスタイル */ +.histogram-empty { + display: flex; + align-items: center; + justify-content: center; + height: 200px; + color: var(--color-text-secondary); +} + +/* レスポンシブ対応 */ +@media (max-width: 768px) { + .histogram-controls { + grid-template-columns: 1fr; + } + + .category-chips { + gap: var(--spacing-xs); + } + + .chip { + padding: var(--spacing-xs) var(--spacing-sm); + font-size: 0.75rem; + } +} + +/* Dark mode adjustments */ +@media (prefers-color-scheme: dark) { + .histogram-controls, + .histogram-chart { + background: var(--color-bg-tertiary); + } + + .select { + background: var(--color-bg-tertiary); + } + + .range-input { + background: var(--color-bg-secondary); + } + + .chip { + background: var(--color-bg-tertiary); + } +} \ No newline at end of file diff --git a/src/components/HistogramPanel/HistogramPanel.tsx b/src/components/HistogramPanel/HistogramPanel.tsx new file mode 100644 index 0000000..ff58553 --- /dev/null +++ b/src/components/HistogramPanel/HistogramPanel.tsx @@ -0,0 +1,260 @@ +import React, { useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAnnotationStore } from '../../stores/useAnnotationStore'; +import { useHistogramStore, HistogramType } from '../../stores/useHistogramStore'; +import { HistogramChart } from '../HistogramChart'; +import { calculateHistogram, exportHistogramAsCSV } from '../../utils/histogram'; +import './HistogramPanel.css'; + +// 値のフォーマット関数 +function formatValue(value: number, type: HistogramType): string { + switch (type) { + case 'aspectRatio': + return value.toFixed(2); + case 'area': + case 'polygonArea': + if (value >= 1000000) { + return `${(value / 1000000).toFixed(1)}M`; + } + if (value >= 1000) { + return `${(value / 1000).toFixed(1)}k`; + } + return Math.round(value).toString(); + default: + return Math.round(value).toString(); + } +} + +export const HistogramPanel: React.FC = () => { + const { t } = useTranslation(); + const { cocoData } = useAnnotationStore(); + const { + settings, + histogramType, + histogramData, + highlightedAnnotations, + setHistogramType, + setBinCount, + setScale, + toggleCategory, + highlightBin, + clearHighlight, + setHistogramData, + } = useHistogramStore(); + + // カテゴリマップを作成 + const categoryMap = useMemo(() => { + const map = new Map(); + if (cocoData?.categories) { + cocoData.categories.forEach(cat => { + map.set(cat.id, cat.name); + }); + } + return map; + }, [cocoData]); + + // ヒストグラムデータを計算 + useEffect(() => { + if (!cocoData || !cocoData.annotations || cocoData.annotations.length === 0) { + setHistogramData(null); + return; + } + + const data = calculateHistogram(cocoData, histogramType, settings); + setHistogramData(data); + }, [cocoData, histogramType, settings, setHistogramData]); + + // ハイライトされたアノテーションを選択状態に反映 + useEffect(() => { + if (highlightedAnnotations.size > 0) { + const { clearSelection, selectAnnotation } = useAnnotationStore.getState(); + clearSelection(); + highlightedAnnotations.forEach(id => selectAnnotation(id, true)); + } + }, [highlightedAnnotations]); + + const handleExportCSV = () => { + if (!histogramData) return; + + const csv = exportHistogramAsCSV(histogramData, categoryMap); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + + link.setAttribute('href', url); + link.setAttribute('download', `histogram_${histogramType}_${new Date().toISOString().slice(0, 10)}.csv`); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const handleBinClick = (binIndex: number) => { + highlightBin(binIndex); + }; + + const handleBinHover = (binIndex: number | null) => { + if (binIndex === null) { + clearHighlight(); + } else { + highlightBin(binIndex); + } + }; + + if (!cocoData || !cocoData.annotations || cocoData.annotations.length === 0) { + return ( +
+

{t('info.noImageLoaded')}

+
+ ); + } + + return ( + <> + {/* コントロール設定セクション */} +
+

{t('histogram.settings')}

+
+ {/* 分布タイプ選択 */} +
+ + +
+ + {/* ビン数設定 */} +
+ + setBinCount(parseInt(e.target.value))} + className="range-input" + /> +
+ + {/* スケール設定 */} +
+ +
+ + +
+
+ + {/* エクスポートボタン */} +
+ + +
+
+
+ + {/* カテゴリフィルタセクション */} + {cocoData.categories && cocoData.categories.length > 0 && ( +
+

{t('histogram.categoryFilter')}

+
+ + {cocoData.categories.map(category => ( + + ))} +
+
+ )} + + {/* ヒストグラムチャート */} + {histogramData && ( + + )} + + {/* 統計情報セクション */} + {histogramData && ( +
+

{t('histogram.statistics')}

+
+
+
{formatValue(histogramData.statistics.mean, histogramData.type)}
+
{t('histogram.mean')}
+
+
+
{formatValue(histogramData.statistics.median, histogramData.type)}
+
{t('histogram.median')}
+
+
+
{formatValue(histogramData.statistics.std, histogramData.type)}
+
{t('histogram.std')}
+
+
+
{formatValue(histogramData.statistics.min, histogramData.type)}
+
{t('histogram.min')}
+
+
+
{formatValue(histogramData.statistics.max, histogramData.type)}
+
{t('histogram.max')}
+
+
+
{histogramData.statistics.total.toLocaleString()}
+
{t('histogram.total')}
+
+
+
+ )} + + ); +}; \ No newline at end of file diff --git a/src/components/HistogramPanel/index.tsx b/src/components/HistogramPanel/index.tsx new file mode 100644 index 0000000..8b1ad55 --- /dev/null +++ b/src/components/HistogramPanel/index.tsx @@ -0,0 +1 @@ +export { HistogramPanel } from './HistogramPanel'; \ No newline at end of file diff --git a/src/hooks/useMenuEvents.ts b/src/hooks/useMenuEvents.ts index 66b997d..c3fbca3 100644 --- a/src/hooks/useMenuEvents.ts +++ b/src/hooks/useMenuEvents.ts @@ -9,7 +9,8 @@ export const useMenuEvents = ( onExportAnnotations: () => void, onShowStatistics: () => void, onShowSettings: () => void, - onShowComparison?: () => void + onShowComparison?: () => void, + onShowHistogram?: () => void ) => { const { zoomIn, zoomOut, resetView, fitToWindow } = useImageStore(); const { showAllCategories, hideAllCategories, clearCocoData } = useAnnotationStore(); @@ -46,6 +47,11 @@ export const useMenuEvents = ( if (onShowComparison) { unsubscribers.push(await listen('menu-compare', onShowComparison)); } + + // Add histogram dialog listener if handler provided + if (onShowHistogram) { + unsubscribers.push(await listen('menu-histogram', onShowHistogram)); + } }; setupListeners(); @@ -61,6 +67,8 @@ export const useMenuEvents = ( onExportAnnotations, onShowStatistics, onShowSettings, + onShowComparison, + onShowHistogram, zoomIn, zoomOut, resetView, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c7913c0..34a9088 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -342,5 +342,32 @@ "polygonIoUWarning": "Polygon IoU uses a grid-based approximation algorithm, not exact area calculation. The values are approximations balanced for accuracy and performance.", "processing": "Processing comparison...", "processingSubMessage": "Analyzing annotations and calculating matches" + }, + "histogram": { + "title": "Annotation Size Distribution", + "noData": "No annotation data available", + "export": "Export CSV", + "settings": "Settings", + "distributionType": "Distribution Type", + "width": "Width", + "height": "Height", + "area": "Area (BBox)", + "polygonArea": "Area (Polygon)", + "aspectRatio": "Aspect Ratio", + "binCount": "Number of Bins", + "scale": "Scale", + "linear": "Linear", + "log": "Logarithmic", + "categoryFilter": "Category Filter", + "allCategories": "All Categories", + "count": "Count", + "percentage": "Percentage", + "statistics": "Statistics", + "mean": "Mean", + "median": "Median", + "std": "Std Dev", + "min": "Min", + "max": "Max", + "total": "Total" } } \ No newline at end of file diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 96c9ede..6092095 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -341,5 +341,32 @@ "polygonIoUWarning": "ポリゴンIoUはグリッドベースの近似アルゴリズムを使用するため、正確な面積計算ではありません。精度と性能のバランスを考慮した近似値となります。", "processing": "比較処理中...", "processingSubMessage": "アノテーションを分析してマッチングを計算しています" + }, + "histogram": { + "title": "アノテーションサイズ分布", + "noData": "アノテーションデータがありません", + "export": "CSV出力", + "settings": "設定", + "distributionType": "分布タイプ", + "width": "幅", + "height": "高さ", + "area": "面積(bbox)", + "polygonArea": "面積(ポリゴン)", + "aspectRatio": "アスペクト比", + "binCount": "ビン数", + "scale": "スケール", + "linear": "線形", + "log": "対数", + "categoryFilter": "カテゴリフィルタ", + "allCategories": "全カテゴリ", + "count": "数", + "percentage": "割合", + "statistics": "統計情報", + "mean": "平均", + "median": "中央値", + "std": "標準偏差", + "min": "最小", + "max": "最大", + "total": "合計" } } \ No newline at end of file diff --git a/src/stores/index.ts b/src/stores/index.ts index b8ef9eb..1d11191 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -4,5 +4,7 @@ export { useToastStore, toast } from './useToastStore'; export { useRecentFilesStore } from './useRecentFilesStore'; export { useSettingsStore, generateCategoryColor } from './useSettingsStore'; export { useLoadingStore } from './useLoadingStore'; +export { useHistogramStore } from './useHistogramStore'; export type { RecentFile } from './useRecentFilesStore'; export type { Language, Theme, TabType } from './useSettingsStore'; +export type { HistogramType, ScaleType, HistogramData, HistogramBin, HistogramStatistics } from './useHistogramStore'; diff --git a/src/stores/useHistogramStore.ts b/src/stores/useHistogramStore.ts new file mode 100644 index 0000000..899bfd0 --- /dev/null +++ b/src/stores/useHistogramStore.ts @@ -0,0 +1,157 @@ +import { create } from 'zustand'; +import { subscribeWithSelector } from 'zustand/middleware'; + +export type HistogramType = 'width' | 'height' | 'area' | 'polygonArea' | 'aspectRatio'; +export type ScaleType = 'linear' | 'log'; + +export interface HistogramBin { + range: [number, number]; + count: number; + categoryBreakdown: Map; + annotationIds: number[]; +} + +export interface HistogramData { + type: HistogramType; + bins: HistogramBin[]; + statistics: HistogramStatistics; +} + +export interface HistogramStatistics { + mean: number; + median: number; + std: number; + min: number; + max: number; + q1: number; + q3: number; + total: number; +} + +export interface HistogramSettings { + binCount: number; + scale: ScaleType; + selectedCategories: Set; + sizeRange: [number, number] | null; +} + +interface HistogramStore { + // 設定 + settings: HistogramSettings; + histogramType: HistogramType; + + // 計算結果 + histogramData: HistogramData | null; + highlightedAnnotations: Set; + + // アクション + setHistogramType: (type: HistogramType) => void; + setBinCount: (count: number) => void; + setScale: (scale: ScaleType) => void; + toggleCategory: (categoryId: number) => void; + setSizeRange: (range: [number, number] | null) => void; + highlightBin: (binIndex: number) => void; + clearHighlight: () => void; + setHistogramData: (data: HistogramData | null) => void; + resetSettings: () => void; +} + +const defaultSettings: HistogramSettings = { + binCount: 20, + scale: 'linear', + selectedCategories: new Set(), + sizeRange: null, +}; + +export const useHistogramStore = create()( + subscribeWithSelector((set) => ({ + // 初期状態 + settings: defaultSettings, + histogramType: 'area', + histogramData: null, + highlightedAnnotations: new Set(), + + // アクション + setHistogramType: (type) => + set(() => ({ + histogramType: type, + // タイプ変更時はデータをクリア(再計算が必要) + histogramData: null, + })), + + setBinCount: (count) => + set((state) => ({ + settings: { + ...state.settings, + binCount: Math.max(5, Math.min(100, count)), + }, + // ビン数変更時はデータをクリア(再計算が必要) + histogramData: null, + })), + + setScale: (scale) => + set((state) => ({ + settings: { + ...state.settings, + scale, + }, + })), + + toggleCategory: (categoryId) => + set((state) => { + const newCategories = new Set(state.settings.selectedCategories); + if (newCategories.has(categoryId)) { + newCategories.delete(categoryId); + } else { + newCategories.add(categoryId); + } + return { + settings: { + ...state.settings, + selectedCategories: newCategories, + }, + // カテゴリ変更時はデータをクリア(再計算が必要) + histogramData: null, + }; + }), + + setSizeRange: (range) => + set((state) => ({ + settings: { + ...state.settings, + sizeRange: range, + }, + // サイズ範囲変更時はデータをクリア(再計算が必要) + histogramData: null, + })), + + highlightBin: (binIndex) => + set((state) => { + if (!state.histogramData || binIndex < 0 || binIndex >= state.histogramData.bins.length) { + return { highlightedAnnotations: new Set() }; + } + const bin = state.histogramData.bins[binIndex]; + return { + highlightedAnnotations: new Set(bin.annotationIds), + }; + }), + + clearHighlight: () => + set(() => ({ + highlightedAnnotations: new Set(), + })), + + setHistogramData: (data) => + set(() => ({ + histogramData: data, + })), + + resetSettings: () => + set(() => ({ + settings: defaultSettings, + histogramType: 'area', + histogramData: null, + highlightedAnnotations: new Set(), + })), + })) +); \ No newline at end of file diff --git a/src/utils/histogram.ts b/src/utils/histogram.ts new file mode 100644 index 0000000..a62a6c2 --- /dev/null +++ b/src/utils/histogram.ts @@ -0,0 +1,275 @@ +import { COCOAnnotation, COCOData } from '../types/coco'; +import { HistogramBin, HistogramData, HistogramStatistics, HistogramType, HistogramSettings } from '../stores/useHistogramStore'; + +// ポリゴンの面積を計算(Shoelace formula) +function calculatePolygonArea(segmentation: number[][]): number { + if (!segmentation || segmentation.length === 0) { + return 0; + } + + let totalArea = 0; + + for (const polygon of segmentation) { + if (polygon.length < 6) continue; // 最低3点(x1,y1,x2,y2,x3,y3)が必要 + + let area = 0; + const pointCount = polygon.length / 2; + + for (let i = 0; i < pointCount; i++) { + const x1 = polygon[i * 2]; + const y1 = polygon[i * 2 + 1]; + const x2 = polygon[((i + 1) % pointCount) * 2]; + const y2 = polygon[((i + 1) % pointCount) * 2 + 1]; + + area += (x1 * y2 - x2 * y1); + } + + totalArea += Math.abs(area) / 2; + } + + return totalArea; +} + +// アノテーションからサイズ値を取得 +export function getAnnotationSize(annotation: COCOAnnotation, type: HistogramType): number { + if (!annotation.bbox || annotation.bbox.length < 4) { + return 0; + } + + const [, , width, height] = annotation.bbox; + + switch (type) { + case 'width': + return width; + case 'height': + return height; + case 'area': + return width * height; + case 'polygonArea': + if (annotation.segmentation && Array.isArray(annotation.segmentation) && annotation.segmentation.length > 0) { + return calculatePolygonArea(annotation.segmentation); + } + // ポリゴンデータがない場合はbbox面積を返す + return width * height; + case 'aspectRatio': + return height > 0 ? width / height : 0; + default: + return 0; + } +} + +// 統計値を計算 +export function calculateStatistics(values: number[]): HistogramStatistics { + if (values.length === 0) { + return { + mean: 0, + median: 0, + std: 0, + min: 0, + max: 0, + q1: 0, + q3: 0, + total: 0, + }; + } + + const sorted = [...values].sort((a, b) => a - b); + const total = values.length; + + // 平均 + const mean = values.reduce((sum, val) => sum + val, 0) / total; + + // 中央値 + const median = total % 2 === 0 + ? (sorted[Math.floor(total / 2) - 1] + sorted[Math.floor(total / 2)]) / 2 + : sorted[Math.floor(total / 2)]; + + // 標準偏差 + const variance = values.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / total; + const std = Math.sqrt(variance); + + // 最小値・最大値 + const min = sorted[0]; + const max = sorted[sorted.length - 1]; + + // 四分位数 + const q1Index = Math.floor(total * 0.25); + const q3Index = Math.floor(total * 0.75); + const q1 = sorted[q1Index]; + const q3 = sorted[q3Index]; + + return { mean, median, std, min, max, q1, q3, total }; +} + +// ビンの範囲を計算 +export function calculateBinRanges( + min: number, + max: number, + binCount: number, + scale: 'linear' | 'log' +): Array<[number, number]> { + const ranges: Array<[number, number]> = []; + + if (scale === 'linear') { + const binWidth = (max - min) / binCount; + for (let i = 0; i < binCount; i++) { + const start = min + i * binWidth; + const end = i === binCount - 1 ? max : min + (i + 1) * binWidth; + ranges.push([start, end]); + } + } else { + // ログスケール + if (min <= 0) { + // ログスケールでは正の値のみ扱う + min = 0.1; + } + const logMin = Math.log10(min); + const logMax = Math.log10(max); + const logBinWidth = (logMax - logMin) / binCount; + + for (let i = 0; i < binCount; i++) { + const logStart = logMin + i * logBinWidth; + const logEnd = logMin + (i + 1) * logBinWidth; + const start = Math.pow(10, logStart); + const end = i === binCount - 1 ? max : Math.pow(10, logEnd); + ranges.push([start, end]); + } + } + + return ranges; +} + +// ヒストグラムデータを計算 +export function calculateHistogram( + cocoData: COCOData, + type: HistogramType, + settings: HistogramSettings +): HistogramData | null { + if (!cocoData.annotations || cocoData.annotations.length === 0) { + return null; + } + + // フィルタリング + let annotations = cocoData.annotations; + + // カテゴリフィルタ + if (settings.selectedCategories.size > 0) { + annotations = annotations.filter(ann => + ann.category_id && settings.selectedCategories.has(ann.category_id) + ); + } + + if (annotations.length === 0) { + return null; + } + + // サイズ値を取得 + const annotationSizes = annotations.map(ann => ({ + annotation: ann, + size: getAnnotationSize(ann, type), + })).filter(item => item.size > 0); + + if (annotationSizes.length === 0) { + return null; + } + + // サイズ範囲フィルタ + if (settings.sizeRange) { + const [minRange, maxRange] = settings.sizeRange; + annotationSizes.filter(item => item.size >= minRange && item.size <= maxRange); + } + + const sizes = annotationSizes.map(item => item.size); + const statistics = calculateStatistics(sizes); + + // ビンの範囲を計算 + const binRanges = calculateBinRanges( + statistics.min, + statistics.max, + settings.binCount, + settings.scale + ); + + // ビンを作成 + const bins: HistogramBin[] = binRanges.map(range => ({ + range, + count: 0, + categoryBreakdown: new Map(), + annotationIds: [], + })); + + // アノテーションをビンに割り当て + annotationSizes.forEach(({ annotation, size }) => { + for (let i = 0; i < bins.length; i++) { + const [start, end] = bins[i].range; + if (size >= start && size <= end) { + bins[i].count++; + bins[i].annotationIds.push(annotation.id); + + // カテゴリ別カウント + if (annotation.category_id) { + const currentCount = bins[i].categoryBreakdown.get(annotation.category_id) || 0; + bins[i].categoryBreakdown.set(annotation.category_id, currentCount + 1); + } + break; + } + } + }); + + return { + type, + bins, + statistics, + }; +} + +// データをCSV形式でエクスポート +export function exportHistogramAsCSV(data: HistogramData, categories: Map): string { + const headers = ['Bin Range', 'Count', 'Percentage']; + + // カテゴリ別ヘッダーを追加 + const categoryIds = Array.from( + new Set(data.bins.flatMap(bin => Array.from(bin.categoryBreakdown.keys()))) + ).sort((a, b) => a - b); + + categoryIds.forEach(id => { + const categoryName = categories.get(id) || `Category ${id}`; + headers.push(categoryName); + }); + + const rows = data.bins.map(bin => { + const percentage = ((bin.count / data.statistics.total) * 100).toFixed(2); + const row = [ + `${bin.range[0].toFixed(2)}-${bin.range[1].toFixed(2)}`, + bin.count.toString(), + `${percentage}%`, + ]; + + // カテゴリ別カウントを追加 + categoryIds.forEach(id => { + const count = bin.categoryBreakdown.get(id) || 0; + row.push(count.toString()); + }); + + return row; + }); + + // 統計情報を追加 + rows.push([]); + rows.push(['Statistics']); + rows.push(['Mean', data.statistics.mean.toFixed(2)]); + rows.push(['Median', data.statistics.median.toFixed(2)]); + rows.push(['Std Dev', data.statistics.std.toFixed(2)]); + rows.push(['Min', data.statistics.min.toFixed(2)]); + rows.push(['Max', data.statistics.max.toFixed(2)]); + rows.push(['Q1', data.statistics.q1.toFixed(2)]); + rows.push(['Q3', data.statistics.q3.toFixed(2)]); + rows.push(['Total', data.statistics.total.toString()]); + + // CSVフォーマット + const csv = [headers, ...rows] + .map(row => row.map(cell => `"${cell}"`).join(',')) + .join('\n'); + + return csv; +} \ No newline at end of file diff --git a/src/utils/index.ts b/src/utils/index.ts index 0fba2f4..21ea0a2 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,2 +1,3 @@ export * from './statistics'; export * from './fieldExtractor'; +export * from './histogram'; From ccff55b8ab902ed729101c1a7bce3a43700281ab Mon Sep 17 00:00:00 2001 From: tact-software Date: Fri, 30 May 2025 08:18:07 +0900 Subject: [PATCH 04/16] =?UTF-8?q?=E7=B5=B1=E8=A8=88=E5=AF=BE=E8=B1=A1?= =?UTF-8?q?=E6=94=B9=E8=89=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/menu.rs | 2 +- .../HistogramPanel/HistogramPanel.tsx | 31 +++++++++++++++++-- src/i18n/locales/en.json | 7 +++-- src/i18n/locales/ja.json | 7 +++-- src/stores/useHistogramStore.ts | 13 ++++++++ src/utils/histogram.ts | 8 ++++- 6 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 1b93a76..e74f8de 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -109,7 +109,7 @@ pub fn create_menu(app: &tauri::AppHandle) -> Result, Box { const { t } = useTranslation(); - const { cocoData } = useAnnotationStore(); + const { cocoData, currentImageId } = useAnnotationStore(); const { settings, histogramType, @@ -40,6 +40,7 @@ export const HistogramPanel: React.FC = () => { highlightBin, clearHighlight, setHistogramData, + setViewMode, } = useHistogramStore(); // カテゴリマップを作成 @@ -60,9 +61,9 @@ export const HistogramPanel: React.FC = () => { return; } - const data = calculateHistogram(cocoData, histogramType, settings); + const data = calculateHistogram(cocoData, histogramType, settings, currentImageId); setHistogramData(data); - }, [cocoData, histogramType, settings, setHistogramData]); + }, [cocoData, histogramType, settings, currentImageId, setHistogramData]); // ハイライトされたアノテーションを選択状態に反映 useEffect(() => { @@ -101,6 +102,9 @@ export const HistogramPanel: React.FC = () => { } }; + // 複数画像があるかチェック + const hasMultipleImages = cocoData && cocoData.images && cocoData.images.length > 1; + if (!cocoData || !cocoData.annotations || cocoData.annotations.length === 0) { return (
@@ -111,6 +115,27 @@ export const HistogramPanel: React.FC = () => { return ( <> + {/* ビューモード選択セクション */} + {hasMultipleImages && ( +
+

{t('histogram.viewMode')}

+
+ + +
+
+ )} + {/* コントロール設定セクション */}

{t('histogram.settings')}

diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 34a9088..15348d4 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -344,7 +344,7 @@ "processingSubMessage": "Analyzing annotations and calculating matches" }, "histogram": { - "title": "Annotation Size Distribution", + "title": "Distribution Histogram", "noData": "No annotation data available", "export": "Export CSV", "settings": "Settings", @@ -368,6 +368,9 @@ "std": "Std Dev", "min": "Min", "max": "Max", - "total": "Total" + "total": "Total", + "viewMode": "View Mode", + "currentImage": "Current Image", + "allImages": "All Images" } } \ No newline at end of file diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 6092095..abbe3ab 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -343,7 +343,7 @@ "processingSubMessage": "アノテーションを分析してマッチングを計算しています" }, "histogram": { - "title": "アノテーションサイズ分布", + "title": "分布ヒストグラム", "noData": "アノテーションデータがありません", "export": "CSV出力", "settings": "設定", @@ -367,6 +367,9 @@ "std": "標準偏差", "min": "最小", "max": "最大", - "total": "合計" + "total": "合計", + "viewMode": "表示対象", + "currentImage": "現在の画像", + "allImages": "全画像" } } \ No newline at end of file diff --git a/src/stores/useHistogramStore.ts b/src/stores/useHistogramStore.ts index 899bfd0..85ba30b 100644 --- a/src/stores/useHistogramStore.ts +++ b/src/stores/useHistogramStore.ts @@ -33,6 +33,7 @@ export interface HistogramSettings { scale: ScaleType; selectedCategories: Set; sizeRange: [number, number] | null; + viewMode: 'current' | 'all'; } interface HistogramStore { @@ -53,6 +54,7 @@ interface HistogramStore { highlightBin: (binIndex: number) => void; clearHighlight: () => void; setHistogramData: (data: HistogramData | null) => void; + setViewMode: (mode: 'current' | 'all') => void; resetSettings: () => void; } @@ -61,6 +63,7 @@ const defaultSettings: HistogramSettings = { scale: 'linear', selectedCategories: new Set(), sizeRange: null, + viewMode: 'all', }; export const useHistogramStore = create()( @@ -146,6 +149,16 @@ export const useHistogramStore = create()( histogramData: data, })), + setViewMode: (mode) => + set((state) => ({ + settings: { + ...state.settings, + viewMode: mode, + }, + // ビューモード変更時はデータをクリア(再計算が必要) + histogramData: null, + })), + resetSettings: () => set(() => ({ settings: defaultSettings, diff --git a/src/utils/histogram.ts b/src/utils/histogram.ts index a62a6c2..a1f11b1 100644 --- a/src/utils/histogram.ts +++ b/src/utils/histogram.ts @@ -143,7 +143,8 @@ export function calculateBinRanges( export function calculateHistogram( cocoData: COCOData, type: HistogramType, - settings: HistogramSettings + settings: HistogramSettings, + currentImageId?: number | null ): HistogramData | null { if (!cocoData.annotations || cocoData.annotations.length === 0) { return null; @@ -152,6 +153,11 @@ export function calculateHistogram( // フィルタリング let annotations = cocoData.annotations; + // ビューモードフィルタ + if (settings.viewMode === 'current' && currentImageId) { + annotations = annotations.filter(ann => ann.image_id === currentImageId); + } + // カテゴリフィルタ if (settings.selectedCategories.size > 0) { annotations = annotations.filter(ann => From fd2f85cfb2b2f5c458dade8aa28ed686994109cf Mon Sep 17 00:00:00 2001 From: tact-software Date: Fri, 30 May 2025 08:22:28 +0900 Subject: [PATCH 05/16] =?UTF-8?q?UI=E6=94=B9=E8=89=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HistogramChart/HistogramChart.css | 8 +- .../HistogramChart/HistogramChart.tsx | 48 ++---- src/components/HistogramChart/index.tsx | 2 +- .../HistogramPanel/HistogramPanel.css | 14 +- .../HistogramPanel/HistogramPanel.tsx | 163 ++++++++++-------- src/components/HistogramPanel/index.tsx | 2 +- .../ImageSelectionDialog.tsx | 1 + src/stores/index.ts | 8 +- src/stores/useHistogramStore.ts | 6 +- src/utils/histogram.ts | 79 +++++---- 10 files changed, 177 insertions(+), 154 deletions(-) diff --git a/src/components/HistogramChart/HistogramChart.css b/src/components/HistogramChart/HistogramChart.css index 52c86da..195e034 100644 --- a/src/components/HistogramChart/HistogramChart.css +++ b/src/components/HistogramChart/HistogramChart.css @@ -78,7 +78,7 @@ .statistics-grid { grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); } - + .statistic-value { font-size: 1.5rem; } @@ -89,12 +89,12 @@ .histogram-chart { background: var(--color-bg-tertiary); } - + .statistic-card { background: var(--color-bg-tertiary); } - + .histogram-tooltip { background: var(--color-bg-tertiary); } -} \ No newline at end of file +} diff --git a/src/components/HistogramChart/HistogramChart.tsx b/src/components/HistogramChart/HistogramChart.tsx index f9b1846..0f6e187 100644 --- a/src/components/HistogramChart/HistogramChart.tsx +++ b/src/components/HistogramChart/HistogramChart.tsx @@ -33,7 +33,7 @@ interface ChartDataPoint { const CustomTooltip: React.FC> = ({ active, payload }) => { const { t } = useTranslation(); - + if (!active || !payload || !payload[0]) { return null; } @@ -46,8 +46,12 @@ const CustomTooltip: React.FC> = ({ active, payload {data.range}
-
{t('histogram.count')}: {data.count}
-
{t('histogram.percentage')}: {data.percentage.toFixed(1)}%
+
+ {t('histogram.count')}: {data.count} +
+
+ {t('histogram.percentage')}: {data.percentage.toFixed(1)}% +
); @@ -59,8 +63,6 @@ export const HistogramChart: React.FC = ({ onBinHover, highlightedBinIndex, }) => { - const { t } = useTranslation(); - // チャートデータを準備 const chartData = useMemo(() => { return data.bins.map((bin, index) => { @@ -72,10 +74,12 @@ export const HistogramChart: React.FC = ({ range: rangeStr, count: bin.count, percentage, - categoryBreakdown: Array.from(bin.categoryBreakdown.entries()).map(([categoryId, count]) => ({ - categoryId, - count, - })), + categoryBreakdown: Array.from(bin.categoryBreakdown.entries()).map( + ([categoryId, count]) => ({ + categoryId, + count, + }) + ), }; }); }, [data]); @@ -141,7 +145,6 @@ export const HistogramChart: React.FC = ({ -
); }; @@ -149,33 +152,14 @@ export const HistogramChart: React.FC = ({ // 範囲のフォーマット function formatRange(range: [number, number], type: HistogramType): string { const [start, end] = range; - + if (type === 'aspectRatio') { return `${start.toFixed(2)}-${end.toFixed(2)}`; } - + if (end >= 1000) { return `${Math.round(start)}-${Math.round(end)}`; } - + return `${Math.round(start)}-${Math.round(end)}`; } - -// 値のフォーマット -function formatValue(value: number, type: HistogramType): string { - switch (type) { - case 'aspectRatio': - return value.toFixed(2); - case 'area': - case 'polygonArea': - if (value >= 1000000) { - return `${(value / 1000000).toFixed(1)}M`; - } - if (value >= 1000) { - return `${(value / 1000).toFixed(1)}k`; - } - return Math.round(value).toString(); - default: - return Math.round(value).toString(); - } -} \ No newline at end of file diff --git a/src/components/HistogramChart/index.tsx b/src/components/HistogramChart/index.tsx index acdee57..feb1135 100644 --- a/src/components/HistogramChart/index.tsx +++ b/src/components/HistogramChart/index.tsx @@ -1 +1 @@ -export { HistogramChart } from './HistogramChart'; \ No newline at end of file +export { HistogramChart } from './HistogramChart'; diff --git a/src/components/HistogramPanel/HistogramPanel.css b/src/components/HistogramPanel/HistogramPanel.css index e93e050..b83cd15 100644 --- a/src/components/HistogramPanel/HistogramPanel.css +++ b/src/components/HistogramPanel/HistogramPanel.css @@ -112,7 +112,7 @@ cursor: pointer; } -.radio-group input[type="radio"] { +.radio-group input[type='radio'] { cursor: pointer; } @@ -175,11 +175,11 @@ .histogram-controls { grid-template-columns: 1fr; } - + .category-chips { gap: var(--spacing-xs); } - + .chip { padding: var(--spacing-xs) var(--spacing-sm); font-size: 0.75rem; @@ -192,16 +192,16 @@ .histogram-chart { background: var(--color-bg-tertiary); } - + .select { background: var(--color-bg-tertiary); } - + .range-input { background: var(--color-bg-secondary); } - + .chip { background: var(--color-bg-tertiary); } -} \ No newline at end of file +} diff --git a/src/components/HistogramPanel/HistogramPanel.tsx b/src/components/HistogramPanel/HistogramPanel.tsx index 07d0c24..dd9ac7f 100644 --- a/src/components/HistogramPanel/HistogramPanel.tsx +++ b/src/components/HistogramPanel/HistogramPanel.tsx @@ -47,7 +47,7 @@ export const HistogramPanel: React.FC = () => { const categoryMap = useMemo(() => { const map = new Map(); if (cocoData?.categories) { - cocoData.categories.forEach(cat => { + cocoData.categories.forEach((cat) => { map.set(cat.id, cat.name); }); } @@ -70,7 +70,7 @@ export const HistogramPanel: React.FC = () => { if (highlightedAnnotations.size > 0) { const { clearSelection, selectAnnotation } = useAnnotationStore.getState(); clearSelection(); - highlightedAnnotations.forEach(id => selectAnnotation(id, true)); + highlightedAnnotations.forEach((id) => selectAnnotation(id, true)); } }, [highlightedAnnotations]); @@ -81,9 +81,12 @@ export const HistogramPanel: React.FC = () => { const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const link = document.createElement('a'); const url = URL.createObjectURL(blob); - + link.setAttribute('href', url); - link.setAttribute('download', `histogram_${histogramType}_${new Date().toISOString().slice(0, 10)}.csv`); + link.setAttribute( + 'download', + `histogram_${histogramType}_${new Date().toISOString().slice(0, 10)}.csv` + ); link.style.visibility = 'hidden'; document.body.appendChild(link); link.click(); @@ -140,69 +143,75 @@ export const HistogramPanel: React.FC = () => {

{t('histogram.settings')}

- {/* 分布タイプ選択 */} -
- - -
- - {/* ビン数設定 */} -
- - setBinCount(parseInt(e.target.value))} - className="range-input" - /> -
+ {/* 分布タイプ選択 */} +
+ + +
- {/* スケール設定 */} -
- -
- + {/* ビン数設定 */} +
+ setBinCount(parseInt(e.target.value))} + className="range-input" + />
-
- {/* エクスポートボタン */} -
- - -
+ {/* スケール設定 */} +
+ +
+ + +
+
+ + {/* エクスポートボタン */} +
+ + +
@@ -215,7 +224,7 @@ export const HistogramPanel: React.FC = () => { className={`chip ${settings.selectedCategories.size === 0 ? 'chip--active' : ''}`} onClick={() => { // 全カテゴリ選択をクリア - cocoData.categories?.forEach(cat => { + cocoData.categories?.forEach((cat) => { if (settings.selectedCategories.has(cat.id)) { toggleCategory(cat.id); } @@ -224,7 +233,7 @@ export const HistogramPanel: React.FC = () => { > {t('histogram.allCategories')} - {cocoData.categories.map(category => ( + {cocoData.categories.map((category) => (
- {/* エクスポートボタン */} + {/* クリップボードコピーボタン */}
- +
@@ -298,6 +373,45 @@ export const HistogramPanel: React.FC = () => {
{t('histogram.total')}
+
+
+ {formatValue(histogramData.statistics.q1, histogramData.type)} +
+
{t('histogram.q1')}
+
+
+
+ {formatValue(histogramData.statistics.q3, histogramData.type)} +
+
{t('histogram.q3')}
+
+
+
+ {formatValue(histogramData.statistics.q3 - histogramData.statistics.q1, histogramData.type)} +
+
{t('histogram.iqr')}
+
+
+
+ {histogramData.statistics.mean > 0 ? + (histogramData.statistics.std / histogramData.statistics.mean * 100).toFixed(1) + '%' : + 'N/A' + } +
+
{t('histogram.cv')}
+
+
+
+ {histogramData.statistics.skewness.toFixed(3)} +
+
{t('histogram.skewness')}
+
+
+
+ {histogramData.statistics.kurtosis.toFixed(3)} +
+
{t('histogram.kurtosis')}
+
)} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 15348d4..ab31a8a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -347,6 +347,7 @@ "title": "Distribution Histogram", "noData": "No annotation data available", "export": "Export CSV", + "copy": "Copy to Clipboard", "settings": "Settings", "distributionType": "Distribution Type", "width": "Width", @@ -371,6 +372,12 @@ "total": "Total", "viewMode": "View Mode", "currentImage": "Current Image", - "allImages": "All Images" + "allImages": "All Images", + "q1": "Q1 (25th percentile)", + "q3": "Q3 (75th percentile)", + "iqr": "IQR (Interquartile Range)", + "cv": "CV (Coefficient of Variation)", + "skewness": "Skewness", + "kurtosis": "Kurtosis" } } \ No newline at end of file diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index abbe3ab..a235698 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -346,6 +346,7 @@ "title": "分布ヒストグラム", "noData": "アノテーションデータがありません", "export": "CSV出力", + "copy": "クリップボードにコピー", "settings": "設定", "distributionType": "分布タイプ", "width": "幅", @@ -370,6 +371,12 @@ "total": "合計", "viewMode": "表示対象", "currentImage": "現在の画像", - "allImages": "全画像" + "allImages": "全画像", + "q1": "第1四分位数", + "q3": "第3四分位数", + "iqr": "四分位範囲", + "cv": "変動係数", + "skewness": "歪度", + "kurtosis": "尖度" } } \ No newline at end of file diff --git a/src/stores/useHistogramStore.ts b/src/stores/useHistogramStore.ts index 43223f6..1712e6a 100644 --- a/src/stores/useHistogramStore.ts +++ b/src/stores/useHistogramStore.ts @@ -26,6 +26,8 @@ export interface HistogramStatistics { q1: number; q3: number; total: number; + skewness: number; + kurtosis: number; } export interface HistogramSettings { diff --git a/src/utils/histogram.ts b/src/utils/histogram.ts index 5fcdd13..a8930b5 100644 --- a/src/utils/histogram.ts +++ b/src/utils/histogram.ts @@ -80,6 +80,8 @@ export function calculateStatistics(values: number[]): HistogramStatistics { q1: 0, q3: 0, total: 0, + skewness: 0, + kurtosis: 0, }; } @@ -109,7 +111,17 @@ export function calculateStatistics(values: number[]): HistogramStatistics { const q1 = sorted[q1Index]; const q3 = sorted[q3Index]; - return { mean, median, std, min, max, q1, q3, total }; + // 歪度 (Skewness) - 3次モーメント + const skewness = std > 0 ? + values.reduce((sum, val) => sum + Math.pow((val - mean) / std, 3), 0) / total : + 0; + + // 尖度 (Kurtosis) - 4次モーメント - 3 (excess kurtosis) + const kurtosis = std > 0 ? + values.reduce((sum, val) => sum + Math.pow((val - mean) / std, 4), 0) / total - 3 : + 0; + + return { mean, median, std, min, max, q1, q3, total, skewness, kurtosis }; } // ビンの範囲を計算 @@ -244,49 +256,72 @@ export function calculateHistogram( // データをCSV形式でエクスポート export function exportHistogramAsCSV(data: HistogramData, categories: Map): string { - const headers = ['Bin Range', 'Count', 'Percentage']; - - // カテゴリ別ヘッダーを追加 - const categoryIds = Array.from( - new Set(data.bins.flatMap((bin) => Array.from(bin.categoryBreakdown.keys()))) - ).sort((a, b) => a - b); - - categoryIds.forEach((id) => { - const categoryName = categories.get(id) || `Category ${id}`; - headers.push(categoryName); - }); + try { + const headers = ['Bin Range', 'Count', 'Percentage']; - const rows = data.bins.map((bin) => { - const percentage = ((bin.count / data.statistics.total) * 100).toFixed(2); - const row = [ - `${bin.range[0].toFixed(2)}-${bin.range[1].toFixed(2)}`, - bin.count.toString(), - `${percentage}%`, - ]; + // カテゴリ別ヘッダーを追加 + const categoryIds = Array.from( + new Set(data.bins.flatMap((bin) => Array.from(bin.categoryBreakdown.keys()))) + ).sort((a, b) => a - b); - // カテゴリ別カウントを追加 categoryIds.forEach((id) => { - const count = bin.categoryBreakdown.get(id) || 0; - row.push(count.toString()); + const categoryName = categories.get(id) || `Category ${id}`; + headers.push(categoryName); }); - return row; - }); + const rows = data.bins.map((bin) => { + const percentage = data.statistics.total > 0 ? + ((bin.count / data.statistics.total) * 100).toFixed(2) : '0.00'; + const row = [ + `${bin.range[0].toFixed(2)}-${bin.range[1].toFixed(2)}`, + bin.count.toString(), + `${percentage}%`, + ]; + + // カテゴリ別カウントを追加 + categoryIds.forEach((id) => { + const count = bin.categoryBreakdown.get(id) || 0; + row.push(count.toString()); + }); + + return row; + }); - // 統計情報を追加 - rows.push([]); - rows.push(['Statistics']); - rows.push(['Mean', data.statistics.mean.toFixed(2)]); - rows.push(['Median', data.statistics.median.toFixed(2)]); - rows.push(['Std Dev', data.statistics.std.toFixed(2)]); - rows.push(['Min', data.statistics.min.toFixed(2)]); - rows.push(['Max', data.statistics.max.toFixed(2)]); - rows.push(['Q1', data.statistics.q1.toFixed(2)]); - rows.push(['Q3', data.statistics.q3.toFixed(2)]); - rows.push(['Total', data.statistics.total.toString()]); - - // CSVフォーマット - const csv = [headers, ...rows].map((row) => row.map((cell) => `"${cell}"`).join(',')).join('\n'); - - return csv; + // 統計情報を追加 + rows.push([]); + rows.push(['Statistics']); + + // 安全な数値フォーマッティング + const safeFixed = (value: number, digits: number): string => { + return isNaN(value) || !isFinite(value) ? 'N/A' : value.toFixed(digits); + }; + + rows.push(['Mean', safeFixed(data.statistics.mean, 2)]); + rows.push(['Median', safeFixed(data.statistics.median, 2)]); + rows.push(['Std Dev', safeFixed(data.statistics.std, 2)]); + rows.push(['Min', safeFixed(data.statistics.min, 2)]); + rows.push(['Max', safeFixed(data.statistics.max, 2)]); + rows.push(['Q1', safeFixed(data.statistics.q1, 2)]); + rows.push(['Q3', safeFixed(data.statistics.q3, 2)]); + rows.push(['IQR', safeFixed(data.statistics.q3 - data.statistics.q1, 2)]); + + const cvValue = data.statistics.mean > 0 ? + ((data.statistics.std / data.statistics.mean) * 100) : NaN; + rows.push(['CV (%)', safeFixed(cvValue, 2)]); + + rows.push(['Skewness', safeFixed(data.statistics.skewness, 3)]); + rows.push(['Kurtosis', safeFixed(data.statistics.kurtosis, 3)]); + rows.push(['Total', data.statistics.total.toString()]); + + // CSVフォーマット + const csv = [headers, ...rows] + .map((row) => row.map((cell) => `"${cell}"`).join(',')) + .join('\n'); + + return csv; + } catch (error) { + console.error('Error in exportHistogramAsCSV:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + throw new Error(`CSV export failed: ${errorMessage}`); + } } From d3e5d3af67c9821efda7842c64e8c28e97511839 Mon Sep 17 00:00:00 2001 From: tact-software Date: Fri, 30 May 2025 17:48:44 +0900 Subject: [PATCH 07/16] =?UTF-8?q?=E3=83=92=E3=83=BC=E3=83=88=E3=83=9E?= =?UTF-8?q?=E3=83=83=E3=83=97=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.en.md | 20 ++ README.md | 20 ++ bun.lock | 70 +++- package.json | 2 + src-tauri/src/menu.rs | 7 + src/App.tsx | 8 +- src/components/HeatmapChart/HeatmapChart.css | 73 ++++ src/components/HeatmapChart/HeatmapChart.tsx | 209 +++++++++++ src/components/HeatmapChart/index.tsx | 1 + .../HeatmapDialog/HeatmapDialog.tsx | 22 ++ src/components/HeatmapDialog/index.tsx | 1 + src/components/HeatmapPanel/HeatmapPanel.css | 288 +++++++++++++++ src/components/HeatmapPanel/HeatmapPanel.tsx | 333 ++++++++++++++++++ src/components/HeatmapPanel/index.tsx | 1 + .../HistogramPanel/HistogramPanel.css | 15 +- .../HistogramPanel/HistogramPanel.tsx | 48 +-- src/hooks/useMenuEvents.ts | 9 +- src/i18n/locales/en.json | 22 ++ src/i18n/locales/ja.json | 22 ++ src/stores/index.ts | 7 + src/stores/useHeatmapStore.ts | 102 ++++++ src/utils/heatmap.ts | 262 ++++++++++++++ src/utils/histogram.ts | 24 +- src/utils/index.ts | 1 + 24 files changed, 1521 insertions(+), 46 deletions(-) create mode 100644 src/components/HeatmapChart/HeatmapChart.css create mode 100644 src/components/HeatmapChart/HeatmapChart.tsx create mode 100644 src/components/HeatmapChart/index.tsx create mode 100644 src/components/HeatmapDialog/HeatmapDialog.tsx create mode 100644 src/components/HeatmapDialog/index.tsx create mode 100644 src/components/HeatmapPanel/HeatmapPanel.css create mode 100644 src/components/HeatmapPanel/HeatmapPanel.tsx create mode 100644 src/components/HeatmapPanel/index.tsx create mode 100644 src/stores/useHeatmapStore.ts create mode 100644 src/utils/heatmap.ts diff --git a/README.en.md b/README.en.md index bc1ae14..2bc666c 100644 --- a/README.en.md +++ b/README.en.md @@ -73,6 +73,26 @@ COAV is a COCO (Common Objects in Context) format annotation viewer designed for - Coverage and overlap rate - Per-image or dataset-wide view toggle +#### About Statistical Information + +The statistical information displayed in this application (histograms, heatmaps, numerical statistics, etc.) are **reference values** intended for data exploration and trend analysis. + +**Important Notes** +- Polygon area calculations use approximation algorithms +- Statistical values depend on filtering settings and bin division settings +- For academic research or formal evaluation, we recommend directly checking the original annotation data + +**Intended Use** +- Dataset overview +- Annotation distribution visualization +- Quality check assistance + +**Disclaimer** +**The developers assume no responsibility for the accuracy of statistical information or analysis results generated by this tool.** +- We are not responsible for any loss or damage resulting from decisions or judgments based on the results of this tool +- For important analysis or research, always cross-reference with original data or other verification methods +- Commercial use or academic research use is at the user's own risk + ### 🎯 Interactive Operations - Annotation selection and highlighting diff --git a/README.md b/README.md index dfce138..8e3da0a 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,26 @@ COAVは、機械学習の研究者、データサイエンティスト、エン - カバレッジ・重複率 - 画像単位/データセット全体の切替表示 +#### 統計情報について + +本アプリケーションで表示される統計情報(ヒストグラム、ヒートマップ、数値統計等)は、データ探索と傾向把握を目的とした**参考値**です。 + +**注意事項** +- ポリゴン面積の計算は近似アルゴリズムを使用 +- 統計値はフィルタリング設定やビン分割設定に依存 +- 学術研究や正式な評価には、元のアノテーションデータを直接確認することを推奨 + +**用途** +- データセットの概要把握 +- アノテーション分布の可視化 +- 品質チェックの補助 + +**免責事項** +**本ツールで生成される統計情報や分析結果の正確性について、開発者は一切の責任を負いません。** +- 本ツールの結果に基づく意思決定や判断による損失・損害について責任を負いかねます +- 重要な分析や研究においては、必ず元データや他の検証手段との照合を行ってください +- 商用利用や学術研究での使用は、ユーザー自身の責任において行ってください + ### 🎯 インタラクティブな操作 - アノテーションの選択・ハイライト diff --git a/bun.lock b/bun.lock index 67905e4..724b10a 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,8 @@ "": { "name": "coav", "dependencies": { + "@nivo/core": "^0.99.0", + "@nivo/heatmap": "^0.99.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.2.2", "@tauri-apps/plugin-fs": "^2.3.0", @@ -189,6 +191,26 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], + "@nivo/annotations": ["@nivo/annotations@0.99.0", "", { "dependencies": { "@nivo/colors": "0.99.0", "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-jCuuXPbvpaqaz4xF7k5dv0OT2ubn5Nt0gWryuTe/8oVsC/9bzSuK8bM9vBty60m9tfO+X8vUYliuaCDwGksC2g=="], + + "@nivo/axes": ["@nivo/axes@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/scales": "0.99.0", "@nivo/text": "0.99.0", "@nivo/theming": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", "@types/d3-format": "^1.4.1", "@types/d3-time-format": "^2.3.1", "d3-format": "^1.4.4", "d3-time-format": "^3.0.0" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-3KschnmEL0acRoa7INSSOSEFwJLm54aZwSev7/r8XxXlkgRBriu6ReZy/FG0wfN+ljZ4GMvx+XyIIf6kxzvrZg=="], + + "@nivo/colors": ["@nivo/colors@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@types/d3-color": "^3.0.0", "@types/d3-scale": "^4.0.8", "@types/d3-scale-chromatic": "^3.0.0", "d3-color": "^3.1.0", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.0.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w=="], + + "@nivo/core": ["@nivo/core@0.99.0", "", { "dependencies": { "@nivo/theming": "0.99.0", "@nivo/tooltip": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", "@types/d3-shape": "^3.1.6", "d3-color": "^3.1.0", "d3-format": "^1.4.4", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.0.0", "d3-shape": "^3.2.0", "d3-time-format": "^3.0.0", "lodash": "^4.17.21", "react-virtualized-auto-sizer": "^1.0.26", "use-debounce": "^10.0.4" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-olCItqhPG3xHL5ei+vg52aB6o+6S+xR2idpkd9RormTTUniZb8U2rOdcQojOojPY5i9kVeQyLFBpV4YfM7OZ9g=="], + + "@nivo/heatmap": ["@nivo/heatmap@0.99.0", "", { "dependencies": { "@nivo/annotations": "0.99.0", "@nivo/axes": "0.99.0", "@nivo/colors": "0.99.0", "@nivo/core": "0.99.0", "@nivo/legends": "0.99.0", "@nivo/scales": "0.99.0", "@nivo/text": "0.99.0", "@nivo/theming": "0.99.0", "@nivo/tooltip": "0.99.0", "@react-spring/core": "9.4.5 || ^9.7.2 || ^10.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", "@types/d3-scale": "^4.0.8", "d3-scale": "^4.0.2" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-cnd5V6XYLvHtQ8GObUyYyEgmAUa5/ERYVmNXR1znA+yzmB+tGthJsOeGar+ntCb43pIL5HbhEaD3YpsYzBxOhQ=="], + + "@nivo/legends": ["@nivo/legends@0.99.0", "", { "dependencies": { "@nivo/colors": "0.99.0", "@nivo/core": "0.99.0", "@nivo/text": "0.99.0", "@nivo/theming": "0.99.0", "@types/d3-scale": "^4.0.8", "d3-scale": "^4.0.2" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-P16FjFqNceuTTZphINAh5p0RF0opu3cCKoWppe2aRD9IuVkvRm/wS5K1YwMCxDzKyKh5v0AuTlu9K6o3/hk8hA=="], + + "@nivo/scales": ["@nivo/scales@0.99.0", "", { "dependencies": { "@types/d3-interpolate": "^3.0.4", "@types/d3-scale": "^4.0.8", "@types/d3-time": "^1.1.1", "@types/d3-time-format": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-time": "^1.0.11", "d3-time-format": "^3.0.0", "lodash": "^4.17.21" } }, "sha512-g/2K4L6L8si6E2BWAHtFVGahtDKbUcO6xHJtlIZMwdzaJc7yB16EpWLK8AfI/A42KadLhJSJqBK3mty+c7YZ+w=="], + + "@nivo/text": ["@nivo/text@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-ho3oZpAZApsJNjsIL5WJSAdg/wjzTBcwo1KiHBlRGUmD+yUWO8qp7V+mnYRhJchwygtRVALlPgZ/rlcW2Xr/MQ=="], + + "@nivo/theming": ["@nivo/theming@0.99.0", "", { "dependencies": { "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-KvXlf0nqBzh/g2hAIV9bzscYvpq1uuO3TnFN3RDXGI72CrbbZFTGzprPju3sy/myVsauv+Bb+V4f5TZ0jkYKRg=="], + + "@nivo/tooltip": ["@nivo/tooltip@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-weoEGR3xAetV4k2P6k96cdamGzKQ5F2Pq+uyDaHr1P3HYArM879Pl+x+TkU0aWjP6wgUZPx/GOBiV1Hb1JxIqg=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -201,6 +223,18 @@ "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + "@react-spring/animated": ["@react-spring/animated@9.4.5", "", { "dependencies": { "@react-spring/shared": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-KWqrtvJSMx6Fj9nMJkhTwM9r6LIriExDRV6YHZV9HKQsaolUFppgkOXpC+rsL1JEtEvKv6EkLLmSqHTnuYjiIA=="], + + "@react-spring/core": ["@react-spring/core@9.4.5", "", { "dependencies": { "@react-spring/animated": "~9.4.5", "@react-spring/rafz": "~9.4.5", "@react-spring/shared": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-83u3FzfQmGMJFwZLAJSwF24/ZJctwUkWtyPD7KYtNagrFeQKUH1I05ZuhmCmqW+2w1KDW1SFWQ43RawqfXKiiQ=="], + + "@react-spring/rafz": ["@react-spring/rafz@9.4.5", "", {}, "sha512-swGsutMwvnoyTRxvqhfJBtGM8Ipx6ks0RkIpNX9F/U7XmyPvBMGd3GgX/mqxZUpdlsuI1zr/jiYw+GXZxAlLcQ=="], + + "@react-spring/shared": ["@react-spring/shared@9.4.5", "", { "dependencies": { "@react-spring/rafz": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-JhMh3nFKsqyag0KM5IIM8BQANGscTdd0mMv3BXsUiMZrcjQTskyfnv5qxEeGWbJGGar52qr5kHuBHtCjQOzniA=="], + + "@react-spring/types": ["@react-spring/types@9.4.5", "", {}, "sha512-mpRIamoHwql0ogxEUh9yr4TP0xU5CWyZxVQeccGkHHF8kPMErtDXJlxyo0lj+telRF35XNihtPTWoflqtyARmg=="], + + "@react-spring/web": ["@react-spring/web@9.4.5", "", { "dependencies": { "@react-spring/animated": "~9.4.5", "@react-spring/core": "~9.4.5", "@react-spring/shared": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0", "react-dom": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-NGAkOtKmOzDEctL7MzRlQGv24sRce++0xAY7KlcxmeVkR7LRSGkoXHaIfm9ObzxPMcPHQYQhf3+X9jepIFNHQA=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.9", "", {}, "sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.41.1", "", { "os": "android", "cpu": "arm" }, "sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw=="], @@ -297,15 +331,21 @@ "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + "@types/d3-format": ["@types/d3-format@1.4.5", "", {}, "sha512-mLxrC1MSWupOSncXN/HOlWUAAIffAEBaI4+PKy2uMPsKe4FNZlk7qrbTjmzJXITQQqBHivaks4Td18azgqnotA=="], + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], + "@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], - "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + "@types/d3-time": ["@types/d3-time@1.1.4", "", {}, "sha512-JIvy2HjRInE+TXOmIGN5LCmeO0hkFZx5f9FZ7kiN+D+YTcc8pptsiLiuHsvwxwC7VVKmJ2ExHUgNlAiV7vQM9g=="], + + "@types/d3-time-format": ["@types/d3-time-format@2.3.4", "", {}, "sha512-xdDXbpVO74EvadI3UDxjxTdR6QIxm1FKzEA/+F8tL4GWWUg/hgvBqf6chql64U5A9ZUGWo7pEu4eNlyLwbKdhg=="], "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], @@ -445,7 +485,7 @@ "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], - "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], + "d3-format": ["d3-format@1.4.5", "", {}, "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ=="], "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], @@ -453,11 +493,13 @@ "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], - "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + "d3-time-format": ["d3-time-format@3.0.0", "", { "dependencies": { "d3-time": "1 - 2" } }, "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag=="], "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], @@ -871,6 +913,8 @@ "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], + "react-virtualized-auto-sizer": ["react-virtualized-auto-sizer@1.0.26", "", { "peerDependencies": { "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A=="], + "recharts": ["recharts@2.15.3", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-EdOPzTwcFSuqtvkDoaM5ws/Km1+WTAO2eizL7rqiG0V2UVhTnz0m7J2i0CjVPUCdEkZImaWvXLbZDS2H5t6GFQ=="], "recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="], @@ -1015,6 +1059,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-debounce": ["use-debounce@10.0.4", "", { "peerDependencies": { "react": "*" } }, "sha512-6Cf7Yr7Wk7Kdv77nnJMf6de4HuDE4dTxKij+RqE9rufDsI6zsbjyAxcH5y2ueJCQAnfgKbzXbZHYlkFwmBlWkw=="], + "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], "vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="], @@ -1075,16 +1121,28 @@ "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], + "@nivo/scales/@types/d3-time-format": ["@types/d3-time-format@3.0.4", "", {}, "sha512-or9DiDnYI1h38J9hxKEsw513+KVuFbEVhl7qdxcaudoiqWWepapUen+2vAriFGexr6W5+P4l9+HJrB39GG+oRg=="], + + "@nivo/scales/d3-time": ["d3-time@1.1.0", "", {}, "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA=="], + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], "@testing-library/dom/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "@types/d3-scale/@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "d3-scale/d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], + + "d3-scale/d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-time-format/d3-time": ["d3-time@2.1.1", "", { "dependencies": { "d3-array": "2" } }, "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ=="], + "eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -1115,6 +1173,8 @@ "test-exclude/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "victory-vendor/@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1123,10 +1183,14 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + "d3-time-format/d3-time/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], + "glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], "test-exclude/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "d3-time-format/d3-time/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], } } diff --git a/package.json b/package.json index 4c8053d..17e3599 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ "version:update:sh": "bash script/update-version.sh" }, "dependencies": { + "@nivo/core": "^0.99.0", + "@nivo/heatmap": "^0.99.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.2.2", "@tauri-apps/plugin-fs": "^2.3.0", diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index e74f8de..008b714 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -113,6 +113,13 @@ pub fn create_menu(app: &tauri::AppHandle) -> Result, Box setShowComparisonDialog(true), - () => setShowHistogramDialog(true) + () => setShowHistogramDialog(true), + openHeatmapModal ); // Handle drag and drop @@ -911,6 +915,8 @@ function App() { + + ({ + type: 'sequential' as const, + scheme: scheme, + minValue: 0, + maxValue: maxValue, +}); + +export const HeatmapChart: React.FC = ({ data, colorScale }) => { + // Nivoフォーマットに変換 + const nivoData = React.useMemo(() => { + const result: Array<{ + id: string; + data: Array<{ + x: string; + y: number; + }>; + }> = []; + + // Y軸の値を生成(逆順にして上が大きい値になるように) + const yLabels: string[] = []; + for (let j = data.bins[0].length - 1; j >= 0; j--) { + const yMin = data.yMin + (j * (data.yMax - data.yMin)) / data.bins[0].length; + const yMax = data.yMin + ((j + 1) * (data.yMax - data.yMin)) / data.bins[0].length; + yLabels.push(`${yMin.toFixed(1)}-${yMax.toFixed(1)}`); + } + + // X軸ごとにデータを作成 + for (let i = 0; i < data.bins.length; i++) { + const xMin = data.xMin + (i * (data.xMax - data.xMin)) / data.bins.length; + const xMax = data.xMin + ((i + 1) * (data.xMax - data.xMin)) / data.bins.length; + const xLabel = `${xMin.toFixed(1)}-${xMax.toFixed(1)}`; + + const row = { + id: xLabel, + data: data.bins[i].map((bin, j) => ({ + x: yLabels[data.bins[0].length - 1 - j], + y: bin.count, + })), + }; + + result.push(row); + } + + return result; + }, [data]); + + const maxValue = Math.max(...data.bins.flat().map((bin) => bin.count)); + + return ( +
+
+

+ {data.xLabel} × {data.yLabel} +

+ +
+ { + // ラベルが長い場合は省略 + if (value.length > 10) { + return value.substring(0, 10) + '...'; + } + return value; + }, + }} + axisLeft={{ + tickSize: 5, + tickPadding: 5, + tickRotation: 0, + legend: '', + legendPosition: 'middle', + legendOffset: -72, + format: (value) => { + // ラベルが長い場合は省略 + if (value.length > 10) { + return value.substring(0, 10) + '...'; + } + return value; + }, + }} + colors={getColorScheme( + colorScale === 'viridis' + ? 'greens' + : colorScale === 'plasma' + ? 'purples' + : colorScale === 'inferno' + ? 'oranges' + : colorScale === 'magma' + ? 'reds' + : 'blues', + maxValue + )} + emptyColor="#ffffff" + borderColor={{ + from: 'color', + modifiers: [['darker', 0.3]], + }} + labelTextColor={{ + from: 'color', + modifiers: [['darker', 1.8]], + }} + animate={true} + motionConfig="gentle" + hoverTarget="cell" + tooltip={(props) => { + const cell = props.cell; + return ( +
+
+ {data.xLabel}: {cell.serieId} +
+
+ {data.yLabel}: {cell.data.x} +
+
+
+ Count: {cell.value} +
+
+ ); + }} + legends={[ + { + anchor: 'right', + translateX: 30, + translateY: 0, + length: 200, + thickness: 10, + direction: 'column', + tickPosition: 'after', + tickSize: 3, + tickSpacing: 4, + tickOverlap: false, + title: 'Count →', + titleAlign: 'start', + titleOffset: 4, + }, + ]} + /> +
+ +
+ Total: {data.totalCount} + Max: {maxValue} + + Bins: {data.bins.length} × {data.bins[0]?.length || 0} + +
+
+
+ ); +}; diff --git a/src/components/HeatmapChart/index.tsx b/src/components/HeatmapChart/index.tsx new file mode 100644 index 0000000..329cdfe --- /dev/null +++ b/src/components/HeatmapChart/index.tsx @@ -0,0 +1 @@ +export { HeatmapChart } from './HeatmapChart'; diff --git a/src/components/HeatmapDialog/HeatmapDialog.tsx b/src/components/HeatmapDialog/HeatmapDialog.tsx new file mode 100644 index 0000000..710671c --- /dev/null +++ b/src/components/HeatmapDialog/HeatmapDialog.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useHeatmapStore } from '../../stores/useHeatmapStore'; +import { CommonModal } from '../CommonModal'; +import { HeatmapPanel } from '../HeatmapPanel'; + +export const HeatmapDialog: React.FC = () => { + const { t } = useTranslation(); + const { isOpen, closeModal } = useHeatmapStore(); + + return ( + + + + ); +}; diff --git a/src/components/HeatmapDialog/index.tsx b/src/components/HeatmapDialog/index.tsx new file mode 100644 index 0000000..c7ebb37 --- /dev/null +++ b/src/components/HeatmapDialog/index.tsx @@ -0,0 +1 @@ +export { HeatmapDialog } from './HeatmapDialog'; diff --git a/src/components/HeatmapPanel/HeatmapPanel.css b/src/components/HeatmapPanel/HeatmapPanel.css new file mode 100644 index 0000000..eb6176a --- /dev/null +++ b/src/components/HeatmapPanel/HeatmapPanel.css @@ -0,0 +1,288 @@ +/* HeatmapPanel は CommonModal 内で使用されるため、背景色や padding は不要 */ + +/* Statistics Section - 統計情報ダイアログと同じスタイル */ +.statistics-section { + margin-bottom: var(--spacing-2xl); +} + +.statistics-section:last-child { + margin-bottom: 0; +} + +.statistics-section h3 { + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: var(--spacing-md); +} + +.heatmap-controls { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--spacing-md); + padding: var(--spacing-lg); + background: var(--color-bg-secondary); + border-radius: var(--radius-md); + border: 1px solid var(--color-border-primary); +} + +.control-group { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.control-group label { + font-size: 0.875rem; + font-weight: 500; + color: var(--color-text-secondary); +} + +.select { + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: var(--color-bg-primary); + color: var(--color-text-primary); + font-size: 0.875rem; + transition: all 0.2s ease; +} + +.select:hover { + border-color: var(--color-brand-primary); +} + +.select:focus { + outline: none; + border-color: var(--color-brand-primary); + box-shadow: 0 0 0 3px rgba(var(--color-brand-primary-rgb), 0.1); +} + +.range-input { + width: 100%; + height: 4px; + border-radius: 2px; + background: var(--color-bg-tertiary); + outline: none; + -webkit-appearance: none; +} + +.range-input::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--color-brand-primary); + cursor: pointer; + transition: all 0.2s ease; +} + +.range-input::-webkit-slider-thumb:hover { + transform: scale(1.2); + box-shadow: 0 0 0 4px rgba(var(--color-brand-primary-rgb), 0.2); +} + +.range-input::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--color-brand-primary); + cursor: pointer; + border: none; + transition: all 0.2s ease; +} + +.range-input::-moz-range-thumb:hover { + transform: scale(1.2); + box-shadow: 0 0 0 4px rgba(var(--color-brand-primary-rgb), 0.2); +} + +.radio-group { + display: flex; + gap: var(--spacing-md); +} + +.radio-group label { + display: flex; + align-items: center; + gap: var(--spacing-xs); + font-size: 0.875rem; + color: var(--color-text-primary); + cursor: pointer; +} + +.radio-group input[type='radio'] { + cursor: pointer; +} + +.category-filter { + margin-bottom: var(--spacing-xl); +} + +.category-filter h4 { + margin: 0 0 var(--spacing-md) 0; + font-size: 1rem; + font-weight: 600; + color: var(--color-text-primary); +} + +.category-chips { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm); +} + +.chip { + padding: var(--spacing-sm) var(--spacing-md); + border: 2px solid var(--color-border-primary); + border-radius: var(--radius-full); + background: var(--color-bg-secondary); + color: var(--color-text-secondary); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.chip:hover { + border-color: var(--color-brand-primary); + color: var(--color-text-primary); + background: var(--color-bg-primary); + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.chip--active { + background: var(--color-brand-primary); + color: white; + border-color: var(--color-brand-primary); + font-weight: 600; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); +} + +.chip--active:hover { + background: var(--color-brand-primary-dark, var(--color-brand-primary)); + border-color: var(--color-brand-primary-dark, var(--color-brand-primary)); + transform: translateY(-1px); + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2); +} + +/* View Mode Toggle - ヒストグラムと同じスタイル */ +.view-mode-toggle { + display: flex; + gap: var(--spacing-sm); +} + +.view-mode-toggle button { + flex: 1; + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--color-border-primary); + background: var(--color-bg-secondary); + color: var(--color-text-secondary); + border-radius: var(--radius-sm); + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.875rem; + font-weight: 500; +} + +.view-mode-toggle button:hover { + border-color: var(--color-brand-primary); + color: var(--color-text-primary); + background: var(--color-bg-primary); + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.view-mode-toggle button.active { + background: var(--color-brand-primary); + color: white; + border-color: var(--color-brand-primary); + font-weight: 600; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); +} + +/* Empty State - 統計情報ダイアログと同じスタイル */ +.heatmap-empty { + display: flex; + align-items: center; + justify-content: center; + height: 200px; + color: var(--color-text-secondary); +} + +/* Heatmap Chart Section */ +.heatmap-chart-section { + margin: var(--spacing-2xl) 0; + background: var(--color-bg-secondary); + border-radius: var(--radius-md); + padding: var(--spacing-2xl); + border: 1px solid var(--color-border-primary); +} + +/* Statistics Grid */ +.statistics-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--spacing-md); +} + +.statistic-card { + background: var(--color-bg-secondary); + padding: var(--spacing-md); + border-radius: var(--radius-sm); + text-align: center; + border: 1px solid var(--color-border-primary); +} + +.statistic-value { + font-size: 1.5rem; + font-weight: 600; + color: var(--color-brand-primary); + margin-bottom: var(--spacing-xs); +} + +.statistic-label { + font-size: 0.75rem; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 500; +} + +/* レスポンシブ対応 */ +@media (max-width: 768px) { + .heatmap-controls { + grid-template-columns: 1fr; + } + + .category-chips { + gap: var(--spacing-xs); + } + + .chip { + padding: var(--spacing-xs) var(--spacing-sm); + font-size: 0.75rem; + } +} + +/* Dark mode adjustments */ +@media (prefers-color-scheme: dark) { + .heatmap-controls, + .heatmap-chart-section { + background: var(--color-bg-tertiary); + } + + .select { + background: var(--color-bg-tertiary); + } + + .range-input { + background: var(--color-bg-secondary); + } + + .chip { + background: var(--color-bg-tertiary); + } +} diff --git a/src/components/HeatmapPanel/HeatmapPanel.tsx b/src/components/HeatmapPanel/HeatmapPanel.tsx new file mode 100644 index 0000000..333a46e --- /dev/null +++ b/src/components/HeatmapPanel/HeatmapPanel.tsx @@ -0,0 +1,333 @@ +import React, { useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAnnotationStore } from '../../stores/useAnnotationStore'; +import { useHeatmapStore, HeatmapType, HeatmapSettings } from '../../stores/useHeatmapStore'; +import { HeatmapChart } from '../HeatmapChart'; +import { calculateHeatmap, exportHeatmapAsCSV } from '../../utils/heatmap'; +import './HeatmapPanel.css'; + +export const HeatmapPanel: React.FC = () => { + const { t } = useTranslation(); + const { cocoData, currentImageId } = useAnnotationStore(); + const { + settings, + heatmapType, + heatmapData, + setHeatmapType, + setXBins, + setYBins, + setColorScale, + toggleCategory, + setViewMode, + setHeatmapData, + } = useHeatmapStore(); + + // カテゴリマップを作成 + const categoryMap = useMemo(() => { + const map = new Map(); + if (cocoData?.categories) { + cocoData.categories.forEach((cat) => { + map.set(cat.id, cat.name); + }); + } + return map; + }, [cocoData]); + + // ヒートマップデータを計算 + useEffect(() => { + if (!cocoData || !cocoData.annotations || cocoData.annotations.length === 0) { + setHeatmapData(null); + return; + } + + const data = calculateHeatmap(cocoData, heatmapType, settings, currentImageId); + setHeatmapData(data); + }, [cocoData, heatmapType, settings, currentImageId, setHeatmapData]); + + const handleCopyToClipboard = async () => { + if (!heatmapData) { + return; + } + + try { + const csv = exportHeatmapAsCSV(heatmapData, categoryMap); + + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(csv); + + // 成功メッセージを表示 + const toast = document.createElement('div'); + toast.textContent = 'ヒートマップデータをクリップボードにコピーしました'; + toast.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: #28a745; + color: white; + padding: 12px 20px; + border-radius: 4px; + z-index: 10000; + box-shadow: 0 2px 10px rgba(0,0,0,0.2); + font-size: 14px; + `; + + document.body.appendChild(toast); + + setTimeout(() => { + if (document.body.contains(toast)) { + document.body.removeChild(toast); + } + }, 3000); + } else { + throw new Error('Clipboard API not available'); + } + } catch { + // フォールバック: テキストエリアで選択可能にする + const csv = exportHeatmapAsCSV(heatmapData, categoryMap); + + const container = document.createElement('div'); + container.style.cssText = ` + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: white; + border: 2px solid #ccc; + border-radius: 8px; + padding: 20px; + z-index: 10000; + box-shadow: 0 4px 20px rgba(0,0,0,0.3); + max-width: 80%; + max-height: 80%; + `; + + const message = document.createElement('p'); + message.textContent = + 'クリップボードへのコピーが失敗しました。下のテキストを手動でコピーしてください:'; + message.style.cssText = 'margin: 0 0 15px 0; color: #333; line-height: 1.4;'; + + const textarea = document.createElement('textarea'); + textarea.value = csv; + textarea.style.cssText = ` + width: 100%; + height: 200px; + font-family: monospace; + font-size: 12px; + border: 1px solid #ccc; + border-radius: 4px; + padding: 8px; + margin-bottom: 10px; + `; + textarea.readOnly = true; + + const closeButton = document.createElement('button'); + closeButton.textContent = '閉じる'; + closeButton.style.cssText = ` + padding: 8px 16px; + background: #6c757d; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + `; + closeButton.onclick = () => document.body.removeChild(container); + + container.appendChild(message); + container.appendChild(textarea); + container.appendChild(closeButton); + document.body.appendChild(container); + + // テキストエリアを選択状態にする + textarea.focus(); + textarea.select(); + } + }; + + // 複数画像があるかチェック + const hasMultipleImages = cocoData && cocoData.images && cocoData.images.length > 1; + + if (!cocoData || !cocoData.annotations || cocoData.annotations.length === 0) { + return ( +
+

{t('info.noImageLoaded')}

+
+ ); + } + + return ( + <> + {/* ビューモード選択セクション */} + {hasMultipleImages && ( +
+

{t('heatmap.viewMode')}

+
+ + +
+
+ )} + + {/* コントロール設定セクション */} +
+

{t('heatmap.settings')}

+
+ {/* ヒートマップタイプ選択 */} +
+ + +
+ + {/* X軸ビン数設定 */} +
+ + setXBins(parseInt(e.target.value))} + className="range-input" + /> +
+ + {/* Y軸ビン数設定 */} +
+ + setYBins(parseInt(e.target.value))} + className="range-input" + /> +
+ + {/* カラースケール設定 */} +
+ + +
+ + {/* クリップボードコピーボタン */} +
+ + +
+
+
+ + {/* カテゴリフィルタセクション */} + {cocoData.categories && cocoData.categories.length > 0 && ( +
+

{t('heatmap.categoryFilter')}

+
+ + {cocoData.categories.map((category) => ( + + ))} +
+
+ )} + + {/* ヒートマップチャート */} + {heatmapData ? ( +
+ +
+ ) : ( +
+

ヒートマップデータを計算中...

+
+ )} + + {/* 統計情報セクション */} + {heatmapData && ( +
+

{t('heatmap.statistics')}

+
+
+
{heatmapData.totalCount.toLocaleString()}
+
{t('heatmap.totalAnnotations')}
+
+
+
+ {heatmapData.xMin.toFixed(1)} - {heatmapData.xMax.toFixed(1)} +
+
+ {heatmapData.xLabel} {t('heatmap.range')} +
+
+
+
+ {heatmapData.yMin.toFixed(1)} - {heatmapData.yMax.toFixed(1)} +
+
+ {heatmapData.yLabel} {t('heatmap.range')} +
+
+
+
+ )} + + ); +}; diff --git a/src/components/HeatmapPanel/index.tsx b/src/components/HeatmapPanel/index.tsx new file mode 100644 index 0000000..abd8e5b --- /dev/null +++ b/src/components/HeatmapPanel/index.tsx @@ -0,0 +1 @@ +export { HeatmapPanel } from './HeatmapPanel'; diff --git a/src/components/HistogramPanel/HistogramPanel.css b/src/components/HistogramPanel/HistogramPanel.css index b83cd15..976b111 100644 --- a/src/components/HistogramPanel/HistogramPanel.css +++ b/src/components/HistogramPanel/HistogramPanel.css @@ -135,7 +135,7 @@ .chip { padding: var(--spacing-sm) var(--spacing-md); - border: 1px solid var(--color-border-primary); + border: 2px solid var(--color-border-primary); border-radius: var(--radius-full); background: var(--color-bg-secondary); color: var(--color-text-secondary); @@ -147,18 +147,25 @@ .chip:hover { border-color: var(--color-brand-primary); - color: var(--color-brand-primary); + color: var(--color-text-primary); + background: var(--color-bg-primary); + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .chip--active { background: var(--color-brand-primary); color: white; border-color: var(--color-brand-primary); + font-weight: 600; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); } .chip--active:hover { - background: var(--color-brand-primary-dark); - border-color: var(--color-brand-primary-dark); + background: var(--color-brand-primary-dark, var(--color-brand-primary)); + border-color: var(--color-brand-primary-dark, var(--color-brand-primary)); + transform: translateY(-1px); + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2); } /* Empty State - 統計情報ダイアログと同じスタイル */ diff --git a/src/components/HistogramPanel/HistogramPanel.tsx b/src/components/HistogramPanel/HistogramPanel.tsx index 05579b6..8b50d89 100644 --- a/src/components/HistogramPanel/HistogramPanel.tsx +++ b/src/components/HistogramPanel/HistogramPanel.tsx @@ -79,10 +79,10 @@ export const HistogramPanel: React.FC = () => { try { const csv = exportHistogramAsCSV(histogramData, categoryMap); - + if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(csv); - + // 成功メッセージを表示 const toast = document.createElement('div'); toast.textContent = 'ヒストグラムデータをクリップボードにコピーしました'; @@ -98,22 +98,21 @@ export const HistogramPanel: React.FC = () => { box-shadow: 0 2px 10px rgba(0,0,0,0.2); font-size: 14px; `; - + document.body.appendChild(toast); - + setTimeout(() => { if (document.body.contains(toast)) { document.body.removeChild(toast); } }, 3000); - } else { throw new Error('Clipboard API not available'); } - } catch (error) { + } catch { // フォールバック: テキストエリアで選択可能にする const csv = exportHistogramAsCSV(histogramData, categoryMap); - + const container = document.createElement('div'); container.style.cssText = ` position: fixed; @@ -129,11 +128,12 @@ export const HistogramPanel: React.FC = () => { max-width: 80%; max-height: 80%; `; - + const message = document.createElement('p'); - message.textContent = 'クリップボードへのコピーが失敗しました。下のテキストを手動でコピーしてください:'; + message.textContent = + 'クリップボードへのコピーが失敗しました。下のテキストを手動でコピーしてください:'; message.style.cssText = 'margin: 0 0 15px 0; color: #333; line-height: 1.4;'; - + const textarea = document.createElement('textarea'); textarea.value = csv; textarea.style.cssText = ` @@ -147,7 +147,7 @@ export const HistogramPanel: React.FC = () => { margin-bottom: 10px; `; textarea.readOnly = true; - + const closeButton = document.createElement('button'); closeButton.textContent = '閉じる'; closeButton.style.cssText = ` @@ -159,12 +159,12 @@ export const HistogramPanel: React.FC = () => { cursor: pointer; `; closeButton.onclick = () => document.body.removeChild(container); - + container.appendChild(message); container.appendChild(textarea); container.appendChild(closeButton); document.body.appendChild(container); - + // テキストエリアを選択状態にする textarea.focus(); textarea.select(); @@ -387,29 +387,29 @@ export const HistogramPanel: React.FC = () => {
- {formatValue(histogramData.statistics.q3 - histogramData.statistics.q1, histogramData.type)} + {formatValue( + histogramData.statistics.q3 - histogramData.statistics.q1, + histogramData.type + )}
{t('histogram.iqr')}
- {histogramData.statistics.mean > 0 ? - (histogramData.statistics.std / histogramData.statistics.mean * 100).toFixed(1) + '%' : - 'N/A' - } + {histogramData.statistics.mean > 0 + ? ((histogramData.statistics.std / histogramData.statistics.mean) * 100).toFixed( + 1 + ) + '%' + : 'N/A'}
{t('histogram.cv')}
-
- {histogramData.statistics.skewness.toFixed(3)} -
+
{histogramData.statistics.skewness.toFixed(3)}
{t('histogram.skewness')}
-
- {histogramData.statistics.kurtosis.toFixed(3)} -
+
{histogramData.statistics.kurtosis.toFixed(3)}
{t('histogram.kurtosis')}
diff --git a/src/hooks/useMenuEvents.ts b/src/hooks/useMenuEvents.ts index c3fbca3..4bbc9ac 100644 --- a/src/hooks/useMenuEvents.ts +++ b/src/hooks/useMenuEvents.ts @@ -10,7 +10,8 @@ export const useMenuEvents = ( onShowStatistics: () => void, onShowSettings: () => void, onShowComparison?: () => void, - onShowHistogram?: () => void + onShowHistogram?: () => void, + onShowHeatmap?: () => void ) => { const { zoomIn, zoomOut, resetView, fitToWindow } = useImageStore(); const { showAllCategories, hideAllCategories, clearCocoData } = useAnnotationStore(); @@ -52,6 +53,11 @@ export const useMenuEvents = ( if (onShowHistogram) { unsubscribers.push(await listen('menu-histogram', onShowHistogram)); } + + // Add heatmap dialog listener if handler provided + if (onShowHeatmap) { + unsubscribers.push(await listen('menu-heatmap', onShowHeatmap)); + } }; setupListeners(); @@ -69,6 +75,7 @@ export const useMenuEvents = ( onShowSettings, onShowComparison, onShowHistogram, + onShowHeatmap, zoomIn, zoomOut, resetView, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ab31a8a..7184898 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -379,5 +379,27 @@ "cv": "CV (Coefficient of Variation)", "skewness": "Skewness", "kurtosis": "Kurtosis" + }, + "heatmap": { + "title": "2D Heatmap", + "noData": "No annotation data available", + "copy": "Copy to Clipboard", + "settings": "Settings", + "heatmapType": "Heatmap Type", + "widthHeight": "Width × Height", + "centerXY": "Center X × Y", + "areaAspectRatio": "Area (bbox) × Aspect Ratio", + "polygonAreaAspectRatio": "Area (polygon) × Aspect Ratio", + "xBins": "X Bins", + "yBins": "Y Bins", + "colorScale": "Color Scale", + "categoryFilter": "Category Filter", + "allCategories": "All Categories", + "statistics": "Statistics", + "totalAnnotations": "Total Annotations", + "range": "Range", + "viewMode": "View Mode", + "currentImage": "Current Image", + "allImages": "All Images" } } \ No newline at end of file diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index a235698..0d300b1 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -378,5 +378,27 @@ "cv": "変動係数", "skewness": "歪度", "kurtosis": "尖度" + }, + "heatmap": { + "title": "2Dヒートマップ", + "noData": "アノテーションデータがありません", + "copy": "クリップボードにコピー", + "settings": "設定", + "heatmapType": "ヒートマップタイプ", + "widthHeight": "幅 × 高さ", + "centerXY": "中心X × Y", + "areaAspectRatio": "面積 (bbox) × アスペクト比", + "polygonAreaAspectRatio": "面積 (ポリゴン) × アスペクト比", + "xBins": "X方向ビン数", + "yBins": "Y方向ビン数", + "colorScale": "カラースケール", + "categoryFilter": "カテゴリフィルタ", + "allCategories": "全カテゴリ", + "statistics": "統計情報", + "totalAnnotations": "総アノテーション数", + "range": "範囲", + "viewMode": "表示モード", + "currentImage": "現在の画像", + "allImages": "全画像" } } \ No newline at end of file diff --git a/src/stores/index.ts b/src/stores/index.ts index 7105c40..e7dd39a 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -5,6 +5,7 @@ export { useRecentFilesStore } from './useRecentFilesStore'; export { useSettingsStore, generateCategoryColor } from './useSettingsStore'; export { useLoadingStore } from './useLoadingStore'; export { useHistogramStore } from './useHistogramStore'; +export { useHeatmapStore } from './useHeatmapStore'; export type { RecentFile } from './useRecentFilesStore'; export type { Language, Theme, TabType } from './useSettingsStore'; export type { @@ -14,3 +15,9 @@ export type { HistogramBin, HistogramStatistics, } from './useHistogramStore'; +export type { + HeatmapType, + HeatmapSettings, + HeatmapBin as HeatmapBinType, + HeatmapData, +} from './useHeatmapStore'; diff --git a/src/stores/useHeatmapStore.ts b/src/stores/useHeatmapStore.ts new file mode 100644 index 0000000..684f8bd --- /dev/null +++ b/src/stores/useHeatmapStore.ts @@ -0,0 +1,102 @@ +import { create } from 'zustand'; + +export type HeatmapType = 'widthHeight' | 'centerXY' | 'areaAspectRatio' | 'polygonAreaAspectRatio'; + +export interface HeatmapSettings { + xBins: number; + yBins: number; + colorScale: 'viridis' | 'plasma' | 'inferno' | 'magma' | 'cividis'; + selectedCategories: Set; + viewMode: 'current' | 'all'; +} + +export interface HeatmapBin { + xRange: [number, number]; + yRange: [number, number]; + count: number; + annotations: number[]; + categoryBreakdown: Map; +} + +export interface HeatmapData { + type: HeatmapType; + bins: HeatmapBin[][]; + xLabel: string; + yLabel: string; + xMin: number; + xMax: number; + yMin: number; + yMax: number; + totalCount: number; +} + +interface HeatmapStore { + isOpen: boolean; + settings: HeatmapSettings; + heatmapType: HeatmapType; + heatmapData: HeatmapData | null; + + // Actions + openModal: () => void; + closeModal: () => void; + setHeatmapType: (type: HeatmapType) => void; + setXBins: (bins: number) => void; + setYBins: (bins: number) => void; + setColorScale: (scale: HeatmapSettings['colorScale']) => void; + toggleCategory: (categoryId: number) => void; + setViewMode: (mode: 'current' | 'all') => void; + setHeatmapData: (data: HeatmapData | null) => void; +} + +export const useHeatmapStore = create((set) => ({ + isOpen: false, + settings: { + xBins: 20, + yBins: 20, + colorScale: 'viridis', + selectedCategories: new Set(), + viewMode: 'current', + }, + heatmapType: 'widthHeight', + heatmapData: null, + + openModal: () => set({ isOpen: true }), + closeModal: () => set({ isOpen: false }), + + setHeatmapType: (type) => set({ heatmapType: type }), + + setXBins: (bins) => + set((state) => ({ + settings: { ...state.settings, xBins: bins }, + })), + + setYBins: (bins) => + set((state) => ({ + settings: { ...state.settings, yBins: bins }, + })), + + setColorScale: (scale) => + set((state) => ({ + settings: { ...state.settings, colorScale: scale }, + })), + + toggleCategory: (categoryId) => + set((state) => { + const newCategories = new Set(state.settings.selectedCategories); + if (newCategories.has(categoryId)) { + newCategories.delete(categoryId); + } else { + newCategories.add(categoryId); + } + return { + settings: { ...state.settings, selectedCategories: newCategories }, + }; + }), + + setViewMode: (mode) => + set((state) => ({ + settings: { ...state.settings, viewMode: mode }, + })), + + setHeatmapData: (data) => set({ heatmapData: data }), +})); diff --git a/src/utils/heatmap.ts b/src/utils/heatmap.ts new file mode 100644 index 0000000..040fd23 --- /dev/null +++ b/src/utils/heatmap.ts @@ -0,0 +1,262 @@ +import { COCOData, COCOAnnotation } from '../types/coco'; +import { HeatmapData, HeatmapType, HeatmapSettings, HeatmapBin } from '../stores/useHeatmapStore'; + +// ポリゴンの面積を計算(Shoelace formula) +function calculatePolygonArea(segmentation: number[][]): number { + if (!segmentation || segmentation.length === 0) return 0; + + // 最初のポリゴンのみを使用 + const polygon = segmentation[0]; + if (!polygon || polygon.length < 6) return 0; // 最低3点必要 + + let area = 0; + const n = polygon.length / 2; + + for (let i = 0; i < n; i++) { + const j = (i + 1) % n; + const xi = polygon[i * 2]; + const yi = polygon[i * 2 + 1]; + const xj = polygon[j * 2]; + const yj = polygon[j * 2 + 1]; + area += xi * yj - xj * yi; + } + + return Math.abs(area) / 2; +} + +// アノテーションから2次元の値を取得 +function getAnnotationValues( + annotation: COCOAnnotation, + type: HeatmapType +): [number, number] | null { + const bbox = annotation.bbox; + if (!bbox || bbox.length < 4) return null; + + switch (type) { + case 'widthHeight': + return [bbox[2], bbox[3]]; + + case 'centerXY': { + const centerX = bbox[0] + bbox[2] / 2; + const centerY = bbox[1] + bbox[3] / 2; + return [centerX, centerY]; + } + + case 'areaAspectRatio': { + const area = bbox[2] * bbox[3]; + const aspectRatio = bbox[3] > 0 ? bbox[2] / bbox[3] : 0; + return [area, aspectRatio]; + } + + case 'polygonAreaAspectRatio': { + // ポリゴン面積を計算 + const polygonArea = annotation.segmentation + ? calculatePolygonArea(annotation.segmentation) + : 0; + + // ポリゴンがない場合はbbox面積を使用 + const area = polygonArea > 0 ? polygonArea : bbox[2] * bbox[3]; + + // アスペクト比はbboxから計算(ポリゴンの正確なアスペクト比は複雑) + const aspectRatio = bbox[3] > 0 ? bbox[2] / bbox[3] : 0; + + return [area, aspectRatio]; + } + + default: + return null; + } +} + +// ヒートマップのラベルを取得 +function getHeatmapLabels(type: HeatmapType): { xLabel: string; yLabel: string } { + switch (type) { + case 'widthHeight': + return { xLabel: 'Width', yLabel: 'Height' }; + case 'centerXY': + return { xLabel: 'X', yLabel: 'Y' }; + case 'areaAspectRatio': + return { xLabel: 'Area (bbox)', yLabel: 'Aspect Ratio' }; + case 'polygonAreaAspectRatio': + return { xLabel: 'Area (polygon)', yLabel: 'Aspect Ratio' }; + default: + return { xLabel: 'X', yLabel: 'Y' }; + } +} + +// ヒートマップデータを計算 +export function calculateHeatmap( + cocoData: COCOData, + type: HeatmapType, + settings: HeatmapSettings, + currentImageId?: number | null +): HeatmapData | null { + if (!cocoData.annotations || cocoData.annotations.length === 0) { + return null; + } + + // フィルタリング + let filteredAnnotations = cocoData.annotations; + + // ビューモードによるフィルタリング + if (settings.viewMode === 'current' && currentImageId !== null && currentImageId !== undefined) { + filteredAnnotations = filteredAnnotations.filter((ann) => ann.image_id === currentImageId); + } + + // カテゴリによるフィルタリング + if (settings.selectedCategories.size > 0) { + filteredAnnotations = filteredAnnotations.filter((ann) => + settings.selectedCategories.has(ann.category_id) + ); + } + + if (filteredAnnotations.length === 0) { + return null; + } + + // 値を収集 + const values: Array<{ x: number; y: number; annotation: COCOAnnotation }> = []; + + for (const annotation of filteredAnnotations) { + const vals = getAnnotationValues(annotation, type); + if (vals) { + values.push({ + x: vals[0], + y: vals[1], + annotation, + }); + } + } + + if (values.length === 0) { + return null; + } + + // 最小値と最大値を計算 + const xValues = values.map((v) => v.x); + const yValues = values.map((v) => v.y); + + let xMin = Math.min(...xValues); + let xMax = Math.max(...xValues); + let yMin = Math.min(...yValues); + let yMax = Math.max(...yValues); + + // ビンの範囲を計算(最小幅を確保) + const xRange = xMax - xMin; + const yRange = yMax - yMin; + + // 範囲が0の場合は最小値を設定 + const xBinWidth = xRange > 0 ? xRange / settings.xBins : 1; + const yBinWidth = yRange > 0 ? yRange / settings.yBins : 1; + + // 範囲が0の場合は、最小値・最大値を調整 + if (xRange === 0) { + xMin -= 0.5; + xMax += 0.5; + } + if (yRange === 0) { + yMin -= 0.5; + yMax += 0.5; + } + + // 2次元のビン配列を初期化 + const bins: HeatmapBin[][] = []; + + for (let i = 0; i < settings.xBins; i++) { + bins[i] = []; + for (let j = 0; j < settings.yBins; j++) { + bins[i][j] = { + xRange: [xMin + i * xBinWidth, xMin + (i + 1) * xBinWidth], + yRange: [yMin + j * yBinWidth, yMin + (j + 1) * yBinWidth], + count: 0, + annotations: [], + categoryBreakdown: new Map(), + }; + } + } + + // 値をビンに割り当て + for (const val of values) { + const xBinIndex = Math.min(Math.floor((val.x - xMin) / xBinWidth), settings.xBins - 1); + const yBinIndex = Math.min(Math.floor((val.y - yMin) / yBinWidth), settings.yBins - 1); + + const bin = bins[xBinIndex][yBinIndex]; + bin.count++; + bin.annotations.push(val.annotation.id); + + // カテゴリ別カウント + const categoryCount = bin.categoryBreakdown.get(val.annotation.category_id) || 0; + bin.categoryBreakdown.set(val.annotation.category_id, categoryCount + 1); + } + + const labels = getHeatmapLabels(type); + + return { + type, + bins, + xLabel: labels.xLabel, + yLabel: labels.yLabel, + xMin, + xMax, + yMin, + yMax, + totalCount: values.length, + }; +} + +// ヒートマップデータをCSV形式でエクスポート +export function exportHeatmapAsCSV(data: HeatmapData, categories: Map): string { + const headers = ['X_Min', 'X_Max', 'Y_Min', 'Y_Max', 'Count']; + + // カテゴリヘッダーを追加 + const categoryIds = Array.from( + new Set(data.bins.flat().flatMap((bin) => Array.from(bin.categoryBreakdown.keys()))) + ).sort((a, b) => a - b); + + categoryIds.forEach((id) => { + const categoryName = categories.get(id) || `Category ${id}`; + headers.push(categoryName); + }); + + const rows: string[][] = []; + + // 各ビンのデータを行に変換 + for (let i = 0; i < data.bins.length; i++) { + for (let j = 0; j < data.bins[i].length; j++) { + const bin = data.bins[i][j]; + if (bin.count === 0) continue; // 空のビンはスキップ + + const row: string[] = [ + bin.xRange[0].toFixed(2), + bin.xRange[1].toFixed(2), + bin.yRange[0].toFixed(2), + bin.yRange[1].toFixed(2), + bin.count.toString(), + ]; + + // カテゴリ別カウントを追加 + categoryIds.forEach((id) => { + const count = bin.categoryBreakdown.get(id) || 0; + row.push(count.toString()); + }); + + rows.push(row); + } + } + + // 統計情報を追加 + rows.push([]); + rows.push(['Statistics']); + rows.push(['Type', data.type]); + rows.push(['X Label', data.xLabel]); + rows.push(['Y Label', data.yLabel]); + rows.push(['X Range', `${data.xMin.toFixed(2)} - ${data.xMax.toFixed(2)}`]); + rows.push(['Y Range', `${data.yMin.toFixed(2)} - ${data.yMax.toFixed(2)}`]); + rows.push(['Total Count', data.totalCount.toString()]); + rows.push(['X Bins', data.bins.length.toString()]); + rows.push(['Y Bins', data.bins[0]?.length.toString() || '0']); + + const csv = [headers, ...rows].map((row) => row.map((cell) => `"${cell}"`).join(',')).join('\n'); + + return csv; +} diff --git a/src/utils/histogram.ts b/src/utils/histogram.ts index a8930b5..500f417 100644 --- a/src/utils/histogram.ts +++ b/src/utils/histogram.ts @@ -112,14 +112,12 @@ export function calculateStatistics(values: number[]): HistogramStatistics { const q3 = sorted[q3Index]; // 歪度 (Skewness) - 3次モーメント - const skewness = std > 0 ? - values.reduce((sum, val) => sum + Math.pow((val - mean) / std, 3), 0) / total : - 0; + const skewness = + std > 0 ? values.reduce((sum, val) => sum + Math.pow((val - mean) / std, 3), 0) / total : 0; // 尖度 (Kurtosis) - 4次モーメント - 3 (excess kurtosis) - const kurtosis = std > 0 ? - values.reduce((sum, val) => sum + Math.pow((val - mean) / std, 4), 0) / total - 3 : - 0; + const kurtosis = + std > 0 ? values.reduce((sum, val) => sum + Math.pow((val - mean) / std, 4), 0) / total - 3 : 0; return { mean, median, std, min, max, q1, q3, total, skewness, kurtosis }; } @@ -270,8 +268,8 @@ export function exportHistogramAsCSV(data: HistogramData, categories: Map { - const percentage = data.statistics.total > 0 ? - ((bin.count / data.statistics.total) * 100).toFixed(2) : '0.00'; + const percentage = + data.statistics.total > 0 ? ((bin.count / data.statistics.total) * 100).toFixed(2) : '0.00'; const row = [ `${bin.range[0].toFixed(2)}-${bin.range[1].toFixed(2)}`, bin.count.toString(), @@ -290,7 +288,7 @@ export function exportHistogramAsCSV(data: HistogramData, categories: Map { return isNaN(value) || !isFinite(value) ? 'N/A' : value.toFixed(digits); @@ -304,11 +302,11 @@ export function exportHistogramAsCSV(data: HistogramData, categories: Map 0 ? - ((data.statistics.std / data.statistics.mean) * 100) : NaN; + + const cvValue = + data.statistics.mean > 0 ? (data.statistics.std / data.statistics.mean) * 100 : NaN; rows.push(['CV (%)', safeFixed(cvValue, 2)]); - + rows.push(['Skewness', safeFixed(data.statistics.skewness, 3)]); rows.push(['Kurtosis', safeFixed(data.statistics.kurtosis, 3)]); rows.push(['Total', data.statistics.total.toString()]); diff --git a/src/utils/index.ts b/src/utils/index.ts index 21ea0a2..6f3300d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,4 @@ export * from './statistics'; export * from './fieldExtractor'; export * from './histogram'; +export * from './heatmap'; From dce1a3d32c0398e0d2fea6a260fb4d8b75c93f3b Mon Sep 17 00:00:00 2001 From: tact-software Date: Fri, 30 May 2025 17:55:37 +0900 Subject: [PATCH 08/16] =?UTF-8?q?=E3=83=89=E3=82=AD=E3=83=A5=E3=83=A1?= =?UTF-8?q?=E3=83=B3=E3=83=88=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.en.md | 13 ++ CHANGELOG.md | 13 ++ docs/dev/feature-annotation-size-histogram.md | 168 ++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 docs/dev/feature-annotation-size-histogram.md diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index c782133..ed3d6f1 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -11,6 +11,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Annotation Statistical Analysis** + - **Histogram Analysis**: 5 distribution types (width, height, area, polygon area, aspect ratio) + - **Heatmap Analysis**: 4 2D distributions (width×height, center coordinates, area×aspect ratio, etc.) + - **Detailed Statistics**: Display of mean, median, standard deviation, skewness, kurtosis, quartiles, etc. + - **Polygon Area Calculation**: Accurate segmentation area calculation using Shoelace formula + - **Data Export**: Clipboard copy functionality for statistical data + - **Menu Integration**: Shortcuts for Histogram (Ctrl/Cmd+Shift+H) and Heatmap (Ctrl/Cmd+Shift+M) +- **Common Modal Component** + - Unified modal design with blur background effects + - Responsive size support (sm, md, lg, xl) + - Accessibility support (ESC key, overlay click) - **Comparison Mode** - Visual comparison between two COCO datasets (ground truth vs predictions) - Color-coded display of TP (True Positive), FP (False Positive), and FN (False Negative) @@ -27,6 +38,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Detailed matching distribution settings - **Loading Overlay** - Full-screen loading display for long-running operations +- **Disclaimer Addition** + - Added disclaimer in README stating statistical information is for reference purposes with developer liability limitations ### Changed diff --git a/CHANGELOG.md b/CHANGELOG.md index 743a6f2..91182e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,17 @@ ### 追加 +- **アノテーション統計分析機能** + - **ヒストグラム分析**: 5つの分布タイプ(幅、高さ、面積、ポリゴン面積、アスペクト比) + - **ヒートマップ分析**: 4つの2次元分布(幅×高さ、中心座標、面積×アスペクト比等) + - **詳細統計情報**: 平均、中央値、標準偏差、歪度、尖度、四分位数等の表示 + - **ポリゴン面積計算**: Shoelace公式による正確なセグメンテーション面積算出 + - **データエクスポート**: 統計データのクリップボードコピー機能 + - **メニュー統合**: ヒストグラム(Ctrl/Cmd+Shift+H)、ヒートマップ(Ctrl/Cmd+Shift+M)のショートカット +- **共通モーダルコンポーネント** + - 統一されたモーダルデザインとブラー背景効果 + - レスポンシブサイズ対応(sm, md, lg, xl) + - アクセシビリティ対応(ESCキー、オーバーレイクリック) - **比較モード機能** - 2つのCOCOデータセット(正解データと予測結果)の視覚的比較 - TP(真陽性)、FP(偽陽性)、FN(偽陰性)の色分け表示 @@ -27,6 +38,8 @@ - マッチング分布の詳細設定 - **ローディングオーバーレイ** - 長時間処理のための全画面ローディング表示 +- **免責事項の追加** + - 統計情報が参考値である旨と開発者責任制限をREADMEに明記 ### 変更 diff --git a/docs/dev/feature-annotation-size-histogram.md b/docs/dev/feature-annotation-size-histogram.md new file mode 100644 index 0000000..46de2dd --- /dev/null +++ b/docs/dev/feature-annotation-size-histogram.md @@ -0,0 +1,168 @@ +# 機能: アノテーションサイズヒストグラム分析機能 + +## 概要 + +COCOフォーマットのアノテーションデータに対する詳細な統計分析機能を実装。ヒストグラム、ヒートマップ、統計情報の表示機能を追加し、データ探索と傾向把握を支援する包括的な分析ツールを提供。 + +## 実装期間 + +- ブランチ: `feature/annotation-size-histogram` +- ベースコミット: f53bc770cc476191b73922819fee408193ee15e0 (develop) +- コミット数: 6個の主要コミット +- 変更ファイル数: 40ファイル +- 追加行数: 4,475行、削除行数: 1,145行 + +## 主要機能 + +### 1. ヒストグラム分析機能 + +1次元の分布分析を提供し、アノテーションの特性を可視化。 + +#### 機能 + +- 5つの分布タイプ対応(幅、高さ、面積、ポリゴン面積、アスペクト比) +- ビン数調整(5-50個) +- 線形・対数スケール切り替え +- カテゴリ別フィルタリング +- 現在画像・全画像の表示切り替え +- 詳細統計情報表示(平均、中央値、標準偏差、歪度、尖度等) +- CSVデータのクリップボードコピー機能 + +#### 技術詳細 + +- **ライブラリ**: Recharts 2.15.3を使用したプロフェッショナルなチャート描画 +- **状態管理**: Zustand(useHistogramStore)による独立した状態管理 +- **計算処理**: ポリゴン面積の正確な計算(Shoelace公式) +- **パフォーマンス**: メモ化とUseMemoによる最適化 + +### 2. ヒートマップ分析機能 + +2次元の分布分析により、複数の変数間の関係性を可視化。 + +#### 機能 + +- 4つのヒートマップタイプ(幅×高さ、中心X×Y、面積×アスペクト比、ポリゴン面積×アスペクト比) +- X/Y軸のビン数独立調整(5-50個) +- 5つのカラースケール(Viridis、Plasma、Inferno、Magma、Cividis) +- カテゴリフィルタとビューモード切り替え +- 統計サマリー表示(総数、範囲情報) +- CSVエクスポート機能 + +#### 技術詳細 + +- **ライブラリ**: @nivo/heatmap 0.99.0による高品質なヒートマップ描画 +- **データ処理**: 2次元ビン分割アルゴリズムの独自実装 +- **ポリゴン処理**: Shoelace公式によるポリゴン面積計算 +- **UI統合**: 統一されたモーダルデザインによる一貫したユーザー体験 + +### 3. 共通モーダルコンポーネント + +統計機能に共通のモーダルインターフェースを提供。 + +#### 機能 + +- 統一されたモーダルデザイン +- ブラー背景効果 +- レスポンシブサイズ対応(sm, md, lg, xl) +- アクセシビリティ対応(ESCキー、オーバーレイクリック) + +#### 改善点 + +- 従来の個別モーダル実装を統一 +- 一貫したユーザー体験の提供 +- メンテナンス性の向上 + +### 4. メニュー統合とキーボードショートカット + +統計機能への簡単なアクセスを提供。 + +#### 機能 + +- Tauriメニューシステムとの統合 +- ヒストグラム: Cmd/Ctrl+Shift+H +- ヒートマップ: Cmd/Ctrl+Shift+M +- 統計ダイアログ: Cmd/Ctrl+I(既存) + +#### 技術詳細 + +- **Rustメニュー**: src-tauri/src/menu.rsでのメニュー項目追加 +- **イベント処理**: useMenuEventsフックでのイベントリスナー統合 +- **権限設定**: Tauriケイパビリティでのメニューアクセス許可 + +## ファイル変更 + +### 新規ファイル + +#### コンポーネント +- `src/components/CommonModal/` - 共通モーダルコンポーネント +- `src/components/HistogramChart/` - ヒストグラムチャートコンポーネント +- `src/components/HistogramPanel/` - ヒストグラムパネルコンポーネント +- `src/components/HeatmapChart/` - ヒートマップチャートコンポーネント +- `src/components/HeatmapPanel/` - ヒートマップパネルコンポーネント +- `src/components/HeatmapDialog/` - ヒートマップダイアログコンポーネント + +#### 状態管理 +- `src/stores/useHistogramStore.ts` - ヒストグラム専用状態管理 +- `src/stores/useHeatmapStore.ts` - ヒートマップ専用状態管理 + +#### ユーティリティ +- `src/utils/histogram.ts` - ヒストグラム計算ロジック +- `src/utils/heatmap.ts` - ヒートマップ計算ロジック + +### 変更されたファイル + +主な変更は以下のファイルに加えられました: + +- `src/App.tsx` - 新しいダイアログ統合とメニューイベント処理 +- `src/hooks/useMenuEvents.ts` - ヒストグラム・ヒートマップメニューイベント追加 +- `src/components/ControlPanel/ControlPanel.tsx` - ヒストグラムボタンの追加 +- `src-tauri/src/menu.rs` - Rustメニューシステムでの新項目追加 +- `src/i18n/locales/*.json` - 多言語対応(日本語・英語) +- `README.md`, `README.en.md` - 統計情報の免責事項追加 + +## バグ修正 + +1. **統計ダイアログの適切な統合** + - 既存の統計ダイアログを新しいCommonModalコンポーネントに移行し、一貫性を向上 + +2. **ポリゴン面積計算の正確性** + - Shoelace公式を使用したポリゴン面積計算の実装により、セグメンテーションデータの正確な分析を実現 + +3. **CSVエクスポートの信頼性** + - ブラウザのポップアップブロッカーの影響を回避するため、クリップボードAPIとフォールバック機能を実装 + +## テスト方法 + +この機能は以下の方法でテストできます: + +1. **ヒストグラム機能**: Cmd/Ctrl+Shift+Hで起動、分布タイプとビン数を変更してグラフの動的更新を確認 +2. **ヒートマップ機能**: Cmd/Ctrl+Shift+Mで起動、ヒートマップタイプとカラースケールの変更を確認 +3. **共通機能**: カテゴリフィルタ、ビューモード切り替え、統計情報表示の動作確認 +4. **データエクスポート**: クリップボードコピー機能の動作確認 +5. **レスポンシブ**: 異なる画面サイズでのUI動作確認 + +## 今後の改善点 + +1. **テスト環境の構築**: ユニットテスト・E2Eテストの実装 +2. **パフォーマンス最適化**: 大規模データセット(10万アノテーション以上)での処理速度向上 +3. **追加統計指標**: より高度な統計分析(相関分析、クラスタリング等)の実装 +4. **エクスポート機能拡張**: PDF、PNG形式でのチャートエクスポート +5. **カスタマイズ性向上**: ユーザー定義の統計指標やチャート設定の保存機能 + +## 依存関係 + +新しい外部依存関係: + +- **@nivo/core@0.99.0** - 高品質なデータ可視化コンポーネントの基盤 +- **@nivo/heatmap@0.99.0** - ヒートマップ専用コンポーネント +- **recharts@2.15.3** - React用チャートライブラリ(ヒストグラム用) + +既存ライブラリを活用: +- **React 18** - UI基盤 +- **Zustand** - 状態管理 +- **i18next** - 国際化 +- **Tauri 2.0** - デスクトップアプリケーション基盤 + +## 免責事項 + +統計機能について、データ探索目的の参考値である旨と開発者の責任制限をREADMEに明記。学術研究や商用利用時の適切な検証の重要性を強調。 \ No newline at end of file From e41103f28bb669c20a7be77bb6e440fa0c38da09 Mon Sep 17 00:00:00 2001 From: tact-software Date: Fri, 30 May 2025 17:57:29 +0900 Subject: [PATCH 09/16] =?UTF-8?q?format=E3=82=A8=E3=83=A9=E3=83=BC?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.en.md | 3 +++ README.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/README.en.md b/README.en.md index 2bc666c..e1440c3 100644 --- a/README.en.md +++ b/README.en.md @@ -78,17 +78,20 @@ COAV is a COCO (Common Objects in Context) format annotation viewer designed for The statistical information displayed in this application (histograms, heatmaps, numerical statistics, etc.) are **reference values** intended for data exploration and trend analysis. **Important Notes** + - Polygon area calculations use approximation algorithms - Statistical values depend on filtering settings and bin division settings - For academic research or formal evaluation, we recommend directly checking the original annotation data **Intended Use** + - Dataset overview - Annotation distribution visualization - Quality check assistance **Disclaimer** **The developers assume no responsibility for the accuracy of statistical information or analysis results generated by this tool.** + - We are not responsible for any loss or damage resulting from decisions or judgments based on the results of this tool - For important analysis or research, always cross-reference with original data or other verification methods - Commercial use or academic research use is at the user's own risk diff --git a/README.md b/README.md index 8e3da0a..595209a 100644 --- a/README.md +++ b/README.md @@ -78,17 +78,20 @@ COAVは、機械学習の研究者、データサイエンティスト、エン 本アプリケーションで表示される統計情報(ヒストグラム、ヒートマップ、数値統計等)は、データ探索と傾向把握を目的とした**参考値**です。 **注意事項** + - ポリゴン面積の計算は近似アルゴリズムを使用 - 統計値はフィルタリング設定やビン分割設定に依存 - 学術研究や正式な評価には、元のアノテーションデータを直接確認することを推奨 **用途** + - データセットの概要把握 - アノテーション分布の可視化 - 品質チェックの補助 **免責事項** **本ツールで生成される統計情報や分析結果の正確性について、開発者は一切の責任を負いません。** + - 本ツールの結果に基づく意思決定や判断による損失・損害について責任を負いかねます - 重要な分析や研究においては、必ず元データや他の検証手段との照合を行ってください - 商用利用や学術研究での使用は、ユーザー自身の責任において行ってください From e4f0374c33904841407db24e3b1ca06a6adea3ae Mon Sep 17 00:00:00 2001 From: tact-software Date: Sat, 31 May 2025 14:11:53 +0900 Subject: [PATCH 10/16] =?UTF-8?q?=E3=83=8A=E3=83=93=E3=82=B2=E3=83=BC?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=B3=E5=9F=BA=E6=9C=AC=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/mod.rs | 137 ++++- src-tauri/src/lib.rs | 3 +- src/App.tsx | 175 +++++- src/components/FilesPanel/FilesPanel.css | 10 + src/components/FilesPanel/FilesPanel.tsx | 87 ++- .../ImageSelectionDialog.css | 43 +- .../ImageSelectionDialog.tsx | 2 +- src/components/ImageViewer/ImageViewer.tsx | 43 +- .../NavigationPanel/NavigationPanel.css | 409 +++++++++++++ .../NavigationPanel/NavigationPanel.tsx | 564 ++++++++++++++++++ src/components/NavigationPanel/index.tsx | 1 + .../SettingsModal/SettingsModal.tsx | 2 + src/i18n/locales/en.json | 31 + src/i18n/locales/ja.json | 31 + src/stores/index.ts | 1 + src/stores/useImageStore.ts | 8 + src/stores/useNavigationStore.ts | 186 ++++++ src/stores/useSettingsStore.ts | 4 +- 18 files changed, 1672 insertions(+), 65 deletions(-) create mode 100644 src/components/NavigationPanel/NavigationPanel.css create mode 100644 src/components/NavigationPanel/NavigationPanel.tsx create mode 100644 src/components/NavigationPanel/index.tsx create mode 100644 src/stores/useNavigationStore.ts diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6247415..3889909 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,4 +1,5 @@ -use crate::models::COCOData; +use crate::models::{COCOData, COCOImage}; +use serde::{Deserialize, Serialize}; use std::fs; use std::path::Path; @@ -40,3 +41,137 @@ pub async fn load_image(file_path: String) -> Result, String> { Ok(image_data) } + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImageMetadata { + pub id: i64, + pub file_name: String, + pub file_path: String, + pub width: i32, + pub height: i32, + pub file_size: Option, + pub exists: bool, + pub load_error: Option, +} + +#[tauri::command] +#[allow(dead_code)] +pub async fn scan_folder( + path: String, + coco_images: Vec, +) -> Result, String> { + let folder_path = Path::new(&path); + + // Verify folder exists + if !folder_path.exists() || !folder_path.is_dir() { + return Err(format!("Invalid folder path: {}", path)); + } + + let mut image_metadata_list = Vec::new(); + + // If no COCO images provided, scan folder for all image files + if coco_images.is_empty() { + let supported_extensions = ["jpg", "jpeg", "png", "bmp", "gif", "webp"]; + + if let Ok(entries) = fs::read_dir(folder_path) { + let mut id_counter = 1; + for entry in entries.flatten() { + let entry_path = entry.path(); + if entry_path.is_file() { + if let Some(extension) = entry_path.extension().and_then(|e| e.to_str()) { + if supported_extensions.contains(&extension.to_lowercase().as_str()) { + let file_name = entry_path + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or("unknown") + .to_string(); + + let mut metadata = ImageMetadata { + id: id_counter, + file_name: file_name.clone(), + file_path: entry_path.to_string_lossy().to_string(), + width: 0, // Will be determined when image is loaded + height: 0, // Will be determined when image is loaded + file_size: None, + exists: true, + load_error: None, + }; + + // Get file size + if let Ok(file_metadata) = fs::metadata(&entry_path) { + metadata.file_size = Some(file_metadata.len()); + } + + image_metadata_list.push(metadata); + id_counter += 1; + } + } + } + } + } + + // Sort by file name for consistent ordering + image_metadata_list.sort_by(|a, b| a.file_name.cmp(&b.file_name)); + } else { + // Process each COCO image + for coco_image in coco_images { + let mut metadata = ImageMetadata { + id: coco_image.id, + file_name: coco_image.file_name.clone(), + file_path: String::new(), + width: coco_image.width, + height: coco_image.height, + file_size: None, + exists: false, + load_error: None, + }; + + // Try to find the image file in the folder + let image_path = folder_path.join(&coco_image.file_name); + + if image_path.exists() && image_path.is_file() { + metadata.file_path = image_path.to_string_lossy().to_string(); + metadata.exists = true; + + // Get file size + if let Ok(file_metadata) = fs::metadata(&image_path) { + metadata.file_size = Some(file_metadata.len()); + } + } else { + // Try to find in subdirectories + let file_name_only = Path::new(&coco_image.file_name) + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or(&coco_image.file_name); + + if let Ok(entries) = fs::read_dir(folder_path) { + for entry in entries.flatten() { + if let Ok(entry_path) = entry.path().canonicalize() { + if entry_path.is_file() + && entry_path.file_name().and_then(|f| f.to_str()) + == Some(file_name_only) + { + metadata.file_path = entry_path.to_string_lossy().to_string(); + metadata.exists = true; + + if let Ok(file_metadata) = fs::metadata(&entry_path) { + metadata.file_size = Some(file_metadata.len()); + } + break; + } + } + } + } + + if !metadata.exists { + metadata.load_error = Some(format!("File not found: {}", coco_image.file_name)); + } + } + + image_metadata_list.push(metadata); + } + } + + Ok(image_metadata_list) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 909dd56..f3dfc7c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,7 +2,7 @@ mod commands; mod menu; mod models; -use commands::{load_annotations, load_image, sample_generator::generate_sample_data}; +use commands::{load_annotations, load_image, sample_generator::generate_sample_data, scan_folder}; use menu::create_menu; use tauri::{Emitter, Manager}; @@ -15,6 +15,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ load_annotations, load_image, + scan_folder, generate_sample_data ]) .setup(|app| { diff --git a/src/App.tsx b/src/App.tsx index 04fdf73..0b3a95e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import { CommonModal } from './components/CommonModal'; import { HistogramPanel } from './components/HistogramPanel'; import { HeatmapDialog } from './components/HeatmapDialog'; import { FilesPanel } from './components/FilesPanel'; +import { NavigationPanel } from './components/NavigationPanel'; import { ToastContainer } from './components/Toast'; import { LoadingOverlay } from './components/LoadingOverlay'; import { @@ -27,6 +28,7 @@ import { useSettingsStore, useLoadingStore, useHeatmapStore, + useNavigationStore, toast, } from './stores'; import type { RecentFile, TabType } from './stores'; @@ -55,9 +57,28 @@ function App() { const { addRecentFile } = useRecentFilesStore(); const { isSettingsModalOpen, openSettingsModal, closeSettingsModal, panelLayout } = useSettingsStore(); + const { selectedFolderPath, navigationMode } = useNavigationStore(); const { isLoading, message, subMessage, progress } = useLoadingStore(); - const [activeLeftTab, setActiveLeftTab] = useState(panelLayout.defaultLeftTab); - const [activeRightTab, setActiveRightTab] = useState(panelLayout.defaultRightTab); + const { imageData } = useImageStore(); + + // Check if images are available (either single image or folder) + const hasImagesAvailable = + imageData || (navigationMode === 'folder' && selectedFolderPath !== null); + + const [activeLeftTab, setActiveLeftTab] = useState(() => { + // デフォルトタブが実際に左パネルに存在するかチェック + const defaultTab = panelLayout.defaultLeftTab; + return panelLayout.leftPanelTabs.includes(defaultTab as TabType) + ? defaultTab + : panelLayout.leftPanelTabs[0] || 'control'; + }); + const [activeRightTab, setActiveRightTab] = useState(() => { + // デフォルトタブが実際に右パネルに存在するかチェック + const defaultTab = panelLayout.defaultRightTab; + return panelLayout.rightPanelTabs.includes(defaultTab as TabType) + ? defaultTab + : panelLayout.rightPanelTabs[0] || 'info'; + }); const prevPanelLayoutRef = useRef(panelLayout); const [unifiedPanelVisible, setUnifiedPanelVisible] = useState(false); const [activeUnifiedTab, setActiveUnifiedTab] = useState('control'); @@ -86,9 +107,12 @@ function App() { ); + case 'navigation': + return ; default: return null; } @@ -158,6 +182,21 @@ function App() { ); + case 'navigation': + return ( + + + + + + ); default: return null; } @@ -174,6 +213,8 @@ function App() { return t('detail.title'); case 'files': return t('files.recentFiles'); + case 'navigation': + return t('navigation.title'); default: return ''; } @@ -280,6 +321,15 @@ function App() { const imagePath = selected as string; + // Reset to single mode when opening a single image + const { resetToSingleMode } = useNavigationStore.getState(); + + // Use setTimeout to avoid hook execution order issues + setTimeout(() => { + clearCocoData(); + resetToSingleMode(); + }, 0); + const imageBytes = await invoke('load_image', { filePath: imagePath }); const uint8Array = new Uint8Array(imageBytes); const blob = new Blob([uint8Array]); @@ -289,8 +339,6 @@ function App() { img.onload = () => { setImagePath(imagePath); setImageData(dataUrl, { width: img.width, height: img.height }); - // Clear annotations when loading a new image - clearCocoData(); // Add to recent files addRecentFile({ path: imagePath, @@ -316,6 +364,12 @@ function App() { }, [setImagePath, setImageData, setLoading, setError, clearCocoData, addRecentFile]); const handleOpenAnnotations = useCallback(async () => { + // Check if images are available + if (!hasImagesAvailable) { + toast.error(t('errors.loadAnnotationFailed'), t('errors.imageRequiredFirst')); + return; + } + try { const selected = await open({ multiple: false, @@ -344,15 +398,23 @@ function App() { // Check if there are multiple images if (data.images && data.images.length > 1) { - // Store data temporarily but don't set it in the store yet - setTempCocoData({ data, annotationDir }); - // Dialog will be shown by useEffect when tempCocoData is set + // フォルダモード(ナビゲーション有効)の場合は直接セット + if (navigationMode === 'folder') { + // フォルダ + 複数画像アノテーション → そのまま開く(ナビゲーションモード) + setCocoData(data); + // 最初の画像IDをセット + setCurrentImageId(data.images[0].id); + } else { + // 画像モード(単一画像)の場合は画像選択ダイアログ + // 画像 + 複数画像アノテーション → 画像ID選択ダイアログ + setTempCocoData({ data, annotationDir }); + } } else if (data.images && data.images.length === 1) { - // Single image, set it immediately + // 単一画像、そのまま開く setCocoData(data); setCurrentImageId(data.images[0].id); } else { - // No images, just set the data + // 画像なし、データのみセット setCocoData(data); } @@ -377,12 +439,67 @@ function App() { setError(errorMessage); toast.error(t('errors.loadAnnotationsFailed'), errorMessage); } - }, [setCocoData, setCurrentImageId, setError, addRecentFile, t]); + }, [hasImagesAvailable, setCocoData, setCurrentImageId, setError, addRecentFile, t]); const handleGenerateSample = useCallback(() => { setShowSampleGenerator(true); }, []); + const handleOpenFolder = useCallback(async () => { + try { + const selected = await open({ + directory: true, + multiple: false, + title: t('navigation.selectFolder'), + }); + + // ユーザーがキャンセルした場合 + if (!selected) { + return; // エラーではないので、何もしない + } + + if (selected) { + try { + // Clear annotations immediately when changing folder + clearCocoData(); + + // Store folder path in navigation store first + const { setSelectedFolderPath, clearImageList } = useNavigationStore.getState(); + + // Clear other state after a microtask to avoid hook issues + setTimeout(() => { + // Clear images and image list + const { clearImage } = useImageStore.getState(); + clearImage(); + clearImageList(); + + // Set the new folder path after clearing + setSelectedFolderPath(selected as string); + }, 0); + + toast.success( + 'フォルダを選択しました', + 'アノテーションを読み込むとナビゲーション機能が利用可能になります' + ); + } catch (stateError) { + console.error('Error updating state after folder selection:', stateError); + toast.error( + 'フォルダの設定中にエラーが発生しました', + stateError instanceof Error ? stateError.message : 'Unknown error' + ); + } + } + // キャンセルした場合は何もしない(エラーではない) + } catch (error) { + // ダイアログ表示時のエラーのみキャッチ + console.error('Failed to open folder dialog:', error); + toast.error( + 'フォルダ選択ダイアログの表示に失敗しました', + error instanceof Error ? error.message : 'Unknown error' + ); + } + }, [t, clearCocoData]); + const handleExportAnnotations = useCallback(() => { const { cocoData } = useAnnotationStore.getState(); if (!cocoData) return; @@ -447,6 +564,16 @@ function App() { // Reset dialog state when loading new image setShowImageSelection(false); setTempCocoData(null); + + // Reset to single mode when loading a single image + const { resetToSingleMode } = useNavigationStore.getState(); + + // Use setTimeout to avoid hook execution order issues + setTimeout(() => { + clearCocoData(); + resetToSingleMode(); + }, 0); + const imageBytes = await invoke('load_image', { filePath: imagePath }); const uint8Array = new Uint8Array(imageBytes); const blob = new Blob([uint8Array]); @@ -500,6 +627,12 @@ function App() { // Common function to load annotations from path const loadAnnotationsFromPath = useCallback( async (jsonPath: string) => { + // Check if images are available + if (!hasImagesAvailable) { + toast.error(t('errors.loadAnnotationFailed'), t('errors.imageRequiredFirst')); + return; + } + try { // Reset previous state setShowImageSelection(false); @@ -519,15 +652,23 @@ function App() { // Check if there are multiple images if (data.images && data.images.length > 1) { - // Store data temporarily but don't set it in the store yet - setTempCocoData({ data, annotationDir }); - // Dialog will be shown by useEffect when tempCocoData is set + // フォルダモード(ナビゲーション有効)の場合は直接セット + if (navigationMode === 'folder') { + // フォルダ + 複数画像アノテーション → そのまま開く(ナビゲーションモード) + setCocoData(data); + // 最初の画像IDをセット + setCurrentImageId(data.images[0].id); + } else { + // 画像モード(単一画像)の場合は画像選択ダイアログ + // 画像 + 複数画像アノテーション → 画像ID選択ダイアログ + setTempCocoData({ data, annotationDir }); + } } else if (data.images && data.images.length === 1) { - // Single image, set it immediately + // 単一画像、そのまま開く setCocoData(data); setCurrentImageId(data.images[0].id); } else { - // No images, just set the data + // 画像なし、データのみセット setCocoData(data); } @@ -554,6 +695,7 @@ function App() { } }, [ + hasImagesAvailable, setCocoData, setError, addRecentFile, @@ -561,6 +703,7 @@ function App() { isComparing, clearComparison, setCurrentImageId, + t, ] ); @@ -713,7 +856,7 @@ function App() {
- {['control', 'info', 'detail', 'files'].map((tab) => ( + {[...panelLayout.leftPanelTabs, ...panelLayout.rightPanelTabs].map((tab) => ( - +
+ + +
+
+ +

{t('info.recentFiles')}

diff --git a/src/components/ImageSelectionDialog/ImageSelectionDialog.css b/src/components/ImageSelectionDialog/ImageSelectionDialog.css index b7ef23a..872d7ae 100644 --- a/src/components/ImageSelectionDialog/ImageSelectionDialog.css +++ b/src/components/ImageSelectionDialog/ImageSelectionDialog.css @@ -10,21 +10,48 @@ } .image-list { - max-height: 400px; + max-height: 50vh; + min-height: 200px; overflow-y: auto; display: flex; flex-direction: column; - gap: var(--spacing-sm); + gap: var(--spacing-xs); + border: 1px solid var(--color-border-secondary); + border-radius: var(--radius-md); + padding: var(--spacing-sm); + scrollbar-width: thin; + scrollbar-color: var(--color-border-primary) transparent; +} + +.image-list::-webkit-scrollbar { + width: 8px; +} + +.image-list::-webkit-scrollbar-track { + background: transparent; + border-radius: var(--radius-sm); +} + +.image-list::-webkit-scrollbar-thumb { + background: var(--color-border-primary); + border-radius: var(--radius-sm); +} + +.image-list::-webkit-scrollbar-thumb:hover { + background: var(--color-brand-primary); } .image-item { display: flex; align-items: center; - padding: var(--spacing-md); + padding: var(--spacing-sm) var(--spacing-md); + min-height: 64px; + height: 64px; border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); + border-radius: var(--radius-sm); cursor: pointer; transition: all 0.2s ease; + flex-shrink: 0; } .image-item:hover { @@ -48,17 +75,25 @@ .image-info { flex: 1; cursor: pointer; + min-width: 0; + overflow: hidden; } .image-name { font-weight: 500; color: var(--color-text-primary); margin-bottom: var(--spacing-xs); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .image-details { font-size: 0.875rem; color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .no-images-message { diff --git a/src/components/ImageSelectionDialog/ImageSelectionDialog.tsx b/src/components/ImageSelectionDialog/ImageSelectionDialog.tsx index cd2205a..a7cfb43 100644 --- a/src/components/ImageSelectionDialog/ImageSelectionDialog.tsx +++ b/src/components/ImageSelectionDialog/ImageSelectionDialog.tsx @@ -51,7 +51,7 @@ const ImageSelectionDialog: React.FC = ({ isOpen={isOpen} onClose={onClose} title={t('imageSelection.title')} - size="md" + size="lg" hasBlur={true} footer={ <> diff --git a/src/components/ImageViewer/ImageViewer.tsx b/src/components/ImageViewer/ImageViewer.tsx index 46aa4f3..a99d86b 100644 --- a/src/components/ImageViewer/ImageViewer.tsx +++ b/src/components/ImageViewer/ImageViewer.tsx @@ -2,7 +2,7 @@ import React, { useRef, useEffect, useState, useCallback } from 'react'; import { Stage, Layer, Image as KonvaImage } from 'react-konva'; import Konva from 'konva'; import { useTranslation } from 'react-i18next'; -import { useImageStore, useAnnotationStore } from '../../stores'; +import { useImageStore, useAnnotationStore, useNavigationStore } from '../../stores'; import AnnotationLayer from '../AnnotationLayer'; import './ImageViewer.css'; @@ -12,6 +12,7 @@ const ImageViewer: React.FC = () => { const stageRef = useRef(null); const [containerSize, setLocalContainerSize] = useState({ width: 0, height: 0 }); const [image, setImage] = useState(null); + const [hasAutoFitted, setHasAutoFitted] = useState(false); const { imageData, @@ -24,11 +25,14 @@ const ImageViewer: React.FC = () => { setContainerSize, isLoading, error, + preserveViewState, } = useImageStore(); const { cocoData, selectedAnnotationIds, shouldCenterOnSelection, currentImageId, isComparing } = useAnnotationStore(); + const { navigationMode } = useNavigationStore(); + // Calculate viewport for culling const getViewport = useCallback(() => { if (!containerSize.width || !containerSize.height) return undefined; @@ -83,20 +87,43 @@ const ImageViewer: React.FC = () => { setImage(img); }; img.src = imageData; + // Only reset auto-fit flag in single mode (not in folder navigation mode) + if (navigationMode === 'single') { + setHasAutoFitted(false); + } } else { setImage(null); + setHasAutoFitted(false); // Reset auto-fit flag when image is cleared } - }, [imageData]); + }, [imageData, navigationMode]); - // Auto-fit image when both image and container size are available + // Auto-fit image when both image and container size are available (first time only or in single mode) useEffect(() => { if (image && containerSize.width > 0 && containerSize.height > 0) { - // Small delay to ensure container size is properly set in store - setTimeout(() => { - fitToWindow(); - }, 0); + // Don't auto-fit if preserveViewState is true + if (preserveViewState) { + return; + } + + // In folder mode, only auto-fit if it's the first time for this session + // In single mode, always auto-fit new images + if (navigationMode === 'single' || !hasAutoFitted) { + // Small delay to ensure container size is properly set in store + setTimeout(() => { + fitToWindow(); + setHasAutoFitted(true); + }, 0); + } } - }, [image, containerSize.width, containerSize.height, fitToWindow]); + }, [ + image, + containerSize.width, + containerSize.height, + fitToWindow, + hasAutoFitted, + navigationMode, + preserveViewState, + ]); // Auto-scroll to selected annotation only when flagged (e.g., from search) useEffect(() => { diff --git a/src/components/NavigationPanel/NavigationPanel.css b/src/components/NavigationPanel/NavigationPanel.css new file mode 100644 index 0000000..8da51ef --- /dev/null +++ b/src/components/NavigationPanel/NavigationPanel.css @@ -0,0 +1,409 @@ +.navigation-panel { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.navigation-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border-color); +} + +.navigation-header h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.folder-info { + padding: 12px; + background-color: var(--background-secondary); + border: 1px solid var(--border-color); + margin: 8px 12px; + border-radius: 6px; +} + +.folder-tag { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background-color: var(--bg-primary); + border: 1px solid var(--primary-color); + border-radius: 4px; + font-size: 13px; + color: var(--text-primary); +} + +.folder-tag svg { + flex-shrink: 0; + color: var(--primary-color); +} + +.folder-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} + +.folder-tag .close-button { + background: none; + border: none; + padding: 4px; + margin-left: 8px; + cursor: pointer; + color: var(--text-secondary); + border-radius: 3px; + transition: + background-color 0.2s, + color 0.2s; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.folder-tag .close-button:hover { + background-color: var(--hover-bg); + color: var(--error-color); +} + +.no-folder-message { + padding: 8px 12px; + text-align: center; + color: var(--text-secondary); + font-size: 13px; + font-style: italic; +} + +.folder-button { + background: none; + border: none; + padding: 4px; + cursor: pointer; + color: var(--text-secondary); + border-radius: 4px; + transition: background-color 0.2s; +} + +.folder-button:hover:not(:disabled) { + background-color: var(--hover-bg); +} + +.folder-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.image-list { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 8px; +} + +.image-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + border-radius: 6px; + transition: all 0.2s; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + border: 1px solid transparent; +} + +.image-item:hover:not(.missing) { + background-color: var(--hover-bg); + border-color: var(--border-color); +} + +.image-item.active { + background-color: var(--primary-color); + border-color: var(--primary-color); + color: white; +} + +.image-item.missing { + opacity: 0.5; + cursor: not-allowed; +} + +.image-item.missing .image-status { + color: var(--warning-color); +} + +.image-status { + font-family: monospace; + width: 16px; + text-align: center; + flex-shrink: 0; + font-size: 12px; +} + +.image-id { + font-family: monospace; + font-size: 11px; + color: var(--text-secondary); + background-color: var(--bg-secondary); + padding: 2px 6px; + border-radius: 3px; + flex-shrink: 0; + min-width: 40px; + text-align: center; +} + +.image-item.active .image-id { + background-color: rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.9); +} + +.image-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 400; +} + +.navigation-controls { + display: flex; + align-items: center; + justify-content: center; + padding: 12px; + margin: 8px 12px; + background-color: var(--bg-secondary); + border-radius: 8px; + border: 1px solid var(--border-color); +} + +.nav-actions { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; +} + +.nav-button { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s; +} + +.nav-button:hover:not(:disabled) { + background-color: var(--hover-bg); + border-color: var(--primary-color); + transform: translateY(-1px); +} + +.nav-button:active:not(:disabled) { + transform: translateY(0); +} + +.nav-button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.id-jump-wrapper { + flex: 1; + max-width: 140px; + display: flex; + align-items: flex-start; + justify-content: center; +} + +.id-jump-container { + width: 100%; + display: flex; + flex-direction: column; + gap: 4px; +} + +.id-jump-input-row { + display: flex; + align-items: center; + gap: 4px; +} + +.id-jump-input { + flex: 1; + padding: 6px 8px; + border: 1px solid #ccc; + border-radius: 4px; + background-color: var(--bg-primary); + color: var(--text-primary); + font-size: 13px; + text-align: center; + outline: none; + transition: all 0.2s; + min-width: 0; +} + +.id-jump-input:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.1); +} + +.id-jump-input.error { + border-color: var(--error-color); +} + +.id-jump-input.error:focus { + border-color: var(--error-color); + box-shadow: 0 0 0 2px rgba(var(--error-rgb), 0.1); +} + +.id-jump-input::placeholder { + color: #999; + font-size: 12px; + opacity: 0.7; +} + +/* Dark mode adjustments */ +[data-theme='dark'] .id-jump-input { + border-color: #555; +} + +[data-theme='dark'] .id-jump-input::placeholder { + color: #666; + opacity: 0.8; +} + +.id-jump-button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background-color: var(--primary-color); + border: none; + border-radius: 4px; + color: white; + font-size: 14px; + font-weight: bold; + cursor: pointer; + transition: all 0.2s; + flex-shrink: 0; +} + +.id-jump-button:hover:not(:disabled) { + background-color: var(--primary-hover); + transform: translateY(-1px); +} + +.id-jump-button:active:not(:disabled) { + transform: translateY(0); +} + +.id-jump-button:disabled { + background-color: var(--bg-secondary); + color: var(--text-secondary); + cursor: not-allowed; + opacity: 0.6; +} + +.id-jump-error { + font-size: 10px; + color: var(--error-color); + text-align: center; + line-height: 1.2; + margin-top: 2px; +} + +.loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + color: white; +} + +.spinner { + width: 32px; + height: 32px; + border: 3px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px; + text-align: center; + color: var(--text-secondary); +} + +.empty-state p { + margin-bottom: 16px; + font-size: 13px; +} + +.select-folder-cta { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background-color: var(--primary-color); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: background-color 0.2s; +} + +.select-folder-cta:hover { + background-color: var(--primary-hover); +} + +/* Dark mode adjustments */ +[data-theme='dark'] .navigation-panel { + background-color: var(--bg-primary); +} + +[data-theme='dark'] .image-item.active { + background-color: var(--primary-color); +} + +[data-theme='dark'] .loading-overlay { + background-color: rgba(0, 0, 0, 0.8); +} diff --git a/src/components/NavigationPanel/NavigationPanel.tsx b/src/components/NavigationPanel/NavigationPanel.tsx new file mode 100644 index 0000000..dbddeb0 --- /dev/null +++ b/src/components/NavigationPanel/NavigationPanel.tsx @@ -0,0 +1,564 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { ChevronLeft, ChevronRight, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useNavigationStore } from '../../stores/useNavigationStore'; +import { useImageStore, useAnnotationStore, useLoadingStore } from '../../stores'; +import { invoke } from '@tauri-apps/api/core'; +import './NavigationPanel.css'; + +interface ImageMetadata { + id: number; + fileName: string; + filePath: string; + width: number; + height: number; + fileSize?: number; + exists: boolean; + loadError?: string; +} + +export const NavigationPanel: React.FC = () => { + const { t } = useTranslation(); + const { + currentIndex, + imageList, + selectedFolderPath, + navigationMode, + setCurrentIndex, + goToNext, + goToPrevious, + setImageList, + resetToSingleMode, + } = useNavigationStore(); + + const { + setImagePath, + setImageData, + setLoading: setImageLoading, + setPreserveViewState, + } = useImageStore(); + const { cocoData: currentAnnotation, setCurrentImageId, clearSelection } = useAnnotationStore(); + const { setLoading: setGlobalLoading } = useLoadingStore(); + const [isScanning, setIsScanning] = useState(false); + const [jumpId, setJumpId] = useState(''); + const [jumpError, setJumpError] = useState(''); + + const [shouldResetToSingle, setShouldResetToSingle] = useState(false); + + const handleCloseFolder = useCallback(() => { + setShouldResetToSingle(true); + }, []); + + // Effect to handle reset to single mode + useEffect(() => { + if (shouldResetToSingle) { + resetToSingleMode(); + setShouldResetToSingle(false); + } + }, [shouldResetToSingle, resetToSingleMode]); + + // Handle image selection + const handleImageSelect = useCallback( + async (index: number) => { + if (index >= 0 && index < imageList.length) { + const image = imageList[index]; + if (image.exists) { + setCurrentIndex(index); + + // Store current zoom and pan BEFORE starting image load + const { zoom, pan } = useImageStore.getState(); + const preservedZoom = zoom; + const preservedPan = { ...pan }; + + // Set flag to preserve view state during image change + setPreserveViewState(true); + + try { + setImageLoading(true); + setGlobalLoading( + true, + t('navigation.loadingImage'), + `${t('navigation.loading')}: ${image.fileName}` + ); + const imageBytes = await invoke('load_image', { filePath: image.filePath }); + const uint8Array = new Uint8Array(imageBytes); + const blob = new Blob([uint8Array]); + const dataUrl = URL.createObjectURL(blob); + + const img = new Image(); + img.onload = () => { + // Set image data first + setImagePath(image.filePath); + setImageData(dataUrl, { width: img.width, height: img.height }); + + // Restore zoom and pan immediately after setting image data + const { setZoom, setPan } = useImageStore.getState(); + setZoom(preservedZoom); + setPan(preservedPan.x, preservedPan.y); + + // Clear the preserve flag after restoration + setPreserveViewState(false); + + // Update current image ID in annotation store for folder mode + if (currentAnnotation && navigationMode === 'folder') { + setCurrentImageId(image.id); + // Clear selected annotations when switching images + clearSelection(); + } + + // End loading after image is fully loaded and displayed + setImageLoading(false); + setGlobalLoading(false); + }; + img.onerror = () => { + console.error('Failed to load image'); + setPreserveViewState(false); + setImageLoading(false); + setGlobalLoading(false); + }; + img.src = dataUrl; + } catch (error) { + console.error('Failed to load image:', error); + setPreserveViewState(false); // Reset flag on error + setImageLoading(false); + setGlobalLoading(false); + } + } + } + }, + [ + imageList, + setCurrentIndex, + setImagePath, + setImageData, + setImageLoading, + currentAnnotation, + navigationMode, + setCurrentImageId, + setPreserveViewState, + clearSelection, + setGlobalLoading, + t, + ] + ); + + // Track if we should auto-update the image when currentIndex changes + const [shouldAutoUpdate, setShouldAutoUpdate] = useState(false); + + // Wrapped navigation functions that enable auto-update + const handleGoToNext = useCallback(() => { + setShouldAutoUpdate(true); + goToNext(); + }, [goToNext]); + + const handleGoToPrevious = useCallback(() => { + setShouldAutoUpdate(true); + goToPrevious(); + }, [goToPrevious]); + + const handleImageClick = useCallback( + (index: number) => { + setShouldAutoUpdate(true); + handleImageSelect(index); + }, + [handleImageSelect] + ); + + // Validate ID input + const validateJumpId = useCallback( + (value: string) => { + if (!value.trim()) { + setJumpError(''); + return; + } + + // Check if it's a valid number (only half-width digits) + if (!/^\d+$/.test(value.trim())) { + setJumpError(t('navigation.invalidIdFormat')); + return; + } + + const id = parseInt(value.trim()); + const image = imageList.find((img) => img.id === id); + + if (!image) { + setJumpError(t('navigation.idNotFound')); + return; + } + + if (!image.exists) { + setJumpError(t('navigation.imageNotExists')); + return; + } + + setJumpError(''); + }, + [imageList, t] + ); + + // Handle ID jump + const handleIdJump = useCallback(() => { + const trimmedId = jumpId.trim(); + if (!trimmedId || jumpError) return; + + const id = parseInt(trimmedId); + const imageIndex = imageList.findIndex((img) => img.id === id); + if (imageIndex !== -1 && imageList[imageIndex].exists) { + setCurrentIndex(imageIndex); + setShouldAutoUpdate(true); + setJumpId(''); + setJumpError(''); + } + }, [jumpId, jumpError, imageList, setCurrentIndex]); + + // Handle input change + const handleJumpIdChange = useCallback( + (value: string) => { + setJumpId(value); + validateJumpId(value); + }, + [validateJumpId] + ); + + // Handle keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (imageList.length === 0) return; + + // Don't handle navigation keys if ID jump input is focused + if (document.activeElement?.getAttribute('data-id-jump') === 'true') { + return; + } + + switch (e.key) { + case 'ArrowRight': + case 'ArrowDown': + e.preventDefault(); + handleGoToNext(); + break; + case 'ArrowLeft': + case 'ArrowUp': + e.preventDefault(); + handleGoToPrevious(); + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [imageList, handleGoToNext, handleGoToPrevious]); + + // Update image when currentIndex changes (but only if needed) + useEffect(() => { + if (shouldAutoUpdate && currentIndex >= 0 && currentIndex < imageList.length) { + const currentImage = imageList[currentIndex]; + const { imagePath } = useImageStore.getState(); + + // Only reload image if it's actually a different image file + const isDifferentImage = imagePath !== currentImage.filePath; + const needsUpdate = shouldAutoUpdate || isDifferentImage; + + if (needsUpdate) { + handleImageSelect(currentIndex); + } + + // Always update annotation ID even if image doesn't change + if ( + currentAnnotation && + navigationMode === 'folder' && + currentAnnotation.currentImageId !== currentImage.id + ) { + setCurrentImageId(currentImage.id); + // Clear selected annotations when switching images + clearSelection(); + } + + // Reset auto-update flag after handling + setShouldAutoUpdate(false); + } + }, [ + currentIndex, + handleImageSelect, + currentAnnotation, + imageList, + shouldAutoUpdate, + navigationMode, + setCurrentImageId, + clearSelection, + ]); + + // Track previous folder path to detect changes + const [prevFolderPath, setPrevFolderPath] = useState(null); + + // Track the previous currentImageId to detect tab switching + const [prevCurrentImageId, setPrevCurrentImageId] = useState(null); + + // Ensure currentIndex matches annotation's currentImageId when tab becomes visible + useEffect(() => { + if (currentAnnotation && currentAnnotation.currentImageId && imageList.length > 0) { + const imageIndex = imageList.findIndex((img) => img.id === currentAnnotation.currentImageId); + + if (imageIndex !== -1) { + const currentImage = imageList[imageIndex]; + const { imagePath } = useImageStore.getState(); + const isImageAlreadyLoaded = imagePath === currentImage?.filePath; + + // Case 1: Different index but same image (tab switching) - just sync index + if (imageIndex !== currentIndex && isImageAlreadyLoaded) { + setCurrentIndex(imageIndex); + setPrevCurrentImageId(currentAnnotation.currentImageId as number | null); + } + // Case 2: Different image - need to load + else if (!isImageAlreadyLoaded) { + setCurrentIndex(imageIndex); + setPrevCurrentImageId(currentAnnotation.currentImageId as number | null); + setShouldAutoUpdate(true); + } + // Case 3: currentImageId changed but same image (annotation updated) - just update tracker + else if (currentAnnotation.currentImageId !== prevCurrentImageId) { + setPrevCurrentImageId(currentAnnotation.currentImageId as number | null); + } + } + } + }, [ + currentAnnotation?.currentImageId, + imageList, + currentIndex, + setCurrentIndex, + prevCurrentImageId, + ]); + + // Auto-scan folder when annotation is loaded and folder is selected or changed + useEffect(() => { + const scanFolder = async () => { + // console.log('📁 Auto-scan folder useEffect', { + // hasAnnotation: !!currentAnnotation, + // currentImageId: currentAnnotation?.currentImageId, + // selectedFolderPath, + // imageListLength: imageList.length, + // folderChanged: selectedFolderPath !== prevFolderPath + // }); + + // Scan if: annotation exists, folder is selected, and either no images or folder changed + if ( + currentAnnotation && + selectedFolderPath && + (imageList.length === 0 || selectedFolderPath !== prevFolderPath) + ) { + setIsScanning(true); + setPrevFolderPath(selectedFolderPath); + try { + const images = await invoke('scan_folder', { + path: selectedFolderPath, + cocoImages: currentAnnotation.images, + }); + setImageList(images); + if (images.length > 0) { + // Preserve the current image if possible, otherwise use currentImageId from annotation + const { imagePath } = useImageStore.getState(); + let targetIndex = 0; + + // First, try to find the currently loaded image + if (imagePath) { + const currentImageIndex = images.findIndex((img) => img.filePath === imagePath); + if (currentImageIndex !== -1 && images[currentImageIndex].exists) { + targetIndex = currentImageIndex; + } + } + + // If not found and annotation has currentImageId, use that + if (targetIndex === 0 && currentAnnotation.currentImageId) { + const imageIndex = images.findIndex( + (img) => img.id === currentAnnotation.currentImageId + ); + if (imageIndex !== -1 && images[imageIndex].exists) { + targetIndex = imageIndex; + } + } + + // If target image doesn't exist, find the first existing image + if (!images[targetIndex]?.exists) { + targetIndex = images.findIndex((img) => img.exists); + if (targetIndex === -1) { + targetIndex = 0; // Fallback to first image if no existing images + } + } + + setCurrentIndex(targetIndex); + + // Only force reload if we're not preserving the currently loaded image + const shouldForceReload = !imagePath || images[targetIndex]?.filePath !== imagePath; + + if (shouldForceReload && images[targetIndex]?.exists) { + setShouldAutoUpdate(true); + } + } + } catch (error) { + console.error('Failed to scan folder:', error); + } finally { + setIsScanning(false); + } + } + }; + + scanFolder(); + }, [ + currentAnnotation?.images, + selectedFolderPath, + imageList.length, + setImageList, + setCurrentIndex, + prevFolderPath, + ]); + + const getImageStatus = (image: ImageMetadata) => { + if (!image.exists) return '⚠'; + if (imageList[currentIndex]?.id === image.id) return '▶'; + return '✓'; + }; + + return ( +
+
+

{t('navigation.title')}

+
+ +
+ {selectedFolderPath ? ( +
+ + + + + {selectedFolderPath.split('/').pop() || + selectedFolderPath.split('\\').pop() || + selectedFolderPath} + + +
+ ) : ( +
{t('navigation.noFolderSelected')}
+ )} +
+ + {imageList.length > 0 && ( + <> +
+
+ + +
+
+
+ handleJumpIdChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleIdJump(); + } else if (e.key === 'Escape') { + setJumpId(''); + setJumpError(''); + } + }} + placeholder="ID" + data-id-jump="true" + className={`id-jump-input ${jumpError ? 'error' : ''}`} + /> + +
+ {jumpError &&
{jumpError}
} +
+
+ + +
+
+ +
+ {imageList.map((image, index) => ( +
handleImageClick(index)} + title={image.filePath} + > + {getImageStatus(image)} + #{image.id} + {image.fileName} +
+ ))} +
+ + )} + + {isScanning && ( +
+
+

{t('navigation.scanning')}

+
+ )} + + {!isScanning && imageList.length === 0 && !currentAnnotation && ( +
+

{selectedFolderPath ? t('navigation.needAnnotation') : t('navigation.needFolder')}

+
+ )} + + {!isScanning && currentAnnotation && imageList.length === 0 && ( +
+

+ {selectedFolderPath + ? t('navigation.noImagesInFolder') + : t('navigation.needFolderForNavigation')} +

+
+ )} +
+ ); +}; diff --git a/src/components/NavigationPanel/index.tsx b/src/components/NavigationPanel/index.tsx new file mode 100644 index 0000000..a749adf --- /dev/null +++ b/src/components/NavigationPanel/index.tsx @@ -0,0 +1 @@ +export { NavigationPanel } from './NavigationPanel'; diff --git a/src/components/SettingsModal/SettingsModal.tsx b/src/components/SettingsModal/SettingsModal.tsx index 9c8e82f..bd77307 100644 --- a/src/components/SettingsModal/SettingsModal.tsx +++ b/src/components/SettingsModal/SettingsModal.tsx @@ -25,6 +25,8 @@ const SettingsModal: React.FC = ({ isOpen, onClose }) => { return t('detail.title'); case 'files': return t('files.recentFiles'); + case 'navigation': + return t('navigation.title'); default: return tab; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7184898..bf69166 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -20,6 +20,7 @@ "help": "Help", "openImage": "Open Image", "openAnnotations": "Open Annotations", + "openFolder": "Open Folder", "exportAnnotations": "Export Annotations", "generateSample": "Generate Sample Data", "settings": "Settings", @@ -146,6 +147,8 @@ "errors": { "loadImageFailed": "Failed to load image", "loadAnnotationsFailed": "Failed to load annotations", + "loadAnnotationFailed": "Failed to load annotation", + "imageRequiredFirst": "Please load an image or select a folder before loading annotations", "invalidImageFormat": "Please check if the file is a valid image format", "invalidJsonFormat": "Please check if the file is a valid JSON format", "exportFailed": "Failed to export", @@ -401,5 +404,33 @@ "viewMode": "View Mode", "currentImage": "Current Image", "allImages": "All Images" + }, + "navigation": { + "title": "Navigation", + "selectFolder": "Select Folder", + "needFolder": "Please select a folder in the Files tab", + "needAnnotation": "Please load annotations in the Files tab", + "needFolderForNavigation": "To use navigation features, please select a folder in the Files tab", + "noAnnotation": "Please load an annotation file first", + "noImages": "No images found. Select a folder to browse images.", + "noImagesInFolder": "No images matching the annotations found in the selected folder", + "scanning": "Scanning folder...", + "previous": "Previous", + "next": "Next", + "first": "First", + "last": "Last", + "play": "Play", + "pause": "Pause", + "speed": "Speed", + "closeFolder": "Close folder", + "noFolderSelected": "No folder selected", + "singleMode": "Single Image Mode", + "folderMode": "Folder Mode", + "loadingImage": "Loading Image", + "loading": "Loading", + "jumpToId": "Jump to ID", + "invalidIdFormat": "Please enter half-width digits only", + "idNotFound": "The specified ID was not found", + "imageNotExists": "Image file does not exist" } } \ No newline at end of file diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0d300b1..1a53852 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -20,6 +20,7 @@ "help": "ヘルプ", "openImage": "画像を開く", "openAnnotations": "アノテーションを開く", + "openFolder": "フォルダを開く", "exportAnnotations": "アノテーションをエクスポート", "generateSample": "サンプルデータを生成", "settings": "設定", @@ -146,6 +147,8 @@ "errors": { "loadImageFailed": "画像の読み込みに失敗しました", "loadAnnotationsFailed": "アノテーションの読み込みに失敗しました", + "loadAnnotationFailed": "アノテーションの読み込みに失敗しました", + "imageRequiredFirst": "アノテーションを読み込む前に、画像またはフォルダを選択してください", "invalidImageFormat": "有効な画像形式であることを確認してください", "invalidJsonFormat": "有効なJSON形式であることを確認してください", "exportFailed": "エクスポートに失敗しました", @@ -400,5 +403,33 @@ "viewMode": "表示モード", "currentImage": "現在の画像", "allImages": "全画像" + }, + "navigation": { + "title": "ナビゲーション", + "selectFolder": "フォルダを選択", + "needFolder": "ファイルタブでフォルダを選択してください", + "needAnnotation": "ファイルタブでアノテーションを読み込んでください", + "needFolderForNavigation": "ナビゲーション機能を使用するには、ファイルタブでフォルダを選択してください", + "noAnnotation": "先にアノテーションファイルを読み込んでください", + "noImages": "画像が見つかりません。フォルダを選択して画像を閲覧してください。", + "noImagesInFolder": "選択したフォルダ内にアノテーションに対応する画像が見つかりません", + "scanning": "フォルダをスキャン中...", + "previous": "前へ", + "next": "次へ", + "first": "最初へ", + "last": "最後へ", + "play": "再生", + "pause": "一時停止", + "speed": "速度", + "closeFolder": "フォルダを閉じる", + "noFolderSelected": "フォルダが選択されていません", + "singleMode": "単一画像モード", + "folderMode": "フォルダモード", + "loadingImage": "画像を読み込み中", + "loading": "読み込み中", + "jumpToId": "IDジャンプ", + "invalidIdFormat": "半角数字のみ入力してください", + "idNotFound": "指定されたIDが見つかりません", + "imageNotExists": "画像ファイルが存在しません" } } \ No newline at end of file diff --git a/src/stores/index.ts b/src/stores/index.ts index e7dd39a..96ad099 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -6,6 +6,7 @@ export { useSettingsStore, generateCategoryColor } from './useSettingsStore'; export { useLoadingStore } from './useLoadingStore'; export { useHistogramStore } from './useHistogramStore'; export { useHeatmapStore } from './useHeatmapStore'; +export { useNavigationStore } from './useNavigationStore'; export type { RecentFile } from './useRecentFilesStore'; export type { Language, Theme, TabType } from './useSettingsStore'; export type { diff --git a/src/stores/useImageStore.ts b/src/stores/useImageStore.ts index f108fe6..341420c 100644 --- a/src/stores/useImageStore.ts +++ b/src/stores/useImageStore.ts @@ -9,6 +9,7 @@ interface ImageState { pan: { x: number; y: number }; isLoading: boolean; error: string | null; + preserveViewState: boolean; // Flag to prevent auto-fit when changing images // Actions setImagePath: (path: string) => void; @@ -20,6 +21,7 @@ interface ImageState { resetView: () => void; setLoading: (loading: boolean) => void; setError: (error: string | null) => void; + setPreserveViewState: (preserve: boolean) => void; // Zoom helpers zoomIn: () => void; @@ -36,6 +38,7 @@ export const useImageStore = create((set, get) => ({ pan: { x: 0, y: 0 }, isLoading: false, error: null, + preserveViewState: false, setImagePath: (path) => { set({ imagePath: path, error: null }); @@ -63,6 +66,7 @@ export const useImageStore = create((set, get) => ({ pan: { x: 0, y: 0 }, isLoading: false, error: null, + preserveViewState: false, }); }, @@ -88,6 +92,10 @@ export const useImageStore = create((set, get) => ({ set({ error, isLoading: false }); }, + setPreserveViewState: (preserve) => { + set({ preserveViewState: preserve }); + }, + zoomIn: () => { const state = get(); const currentZoom = state.zoom; diff --git a/src/stores/useNavigationStore.ts b/src/stores/useNavigationStore.ts new file mode 100644 index 0000000..8e688b6 --- /dev/null +++ b/src/stores/useNavigationStore.ts @@ -0,0 +1,186 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; + +interface ImageMetadata { + id: number; + fileName: string; + filePath: string; + width: number; + height: number; + fileSize?: number; + exists: boolean; + loadError?: string; +} + +interface NavigationStore { + // State + currentIndex: number; + imageList: ImageMetadata[]; + selectedFolderPath: string | null; + navigationMode: 'single' | 'folder'; + + // Basic navigation + setCurrentIndex: (index: number) => void; + goToNext: () => void; + goToPrevious: () => void; + goToFirst: () => void; + goToLast: () => void; + goToImage: (index: number) => void; + + // List management + setImageList: (images: ImageMetadata[]) => void; + updateImageStatus: (index: number, status: Partial) => void; + clearImageList: () => void; + setSelectedFolderPath: (path: string | null) => void; + setNavigationMode: (mode: 'single' | 'folder') => void; + resetToSingleMode: () => void; + + // Filtering + filterByCategory: (categoryIds: number[]) => void; + filterByExistence: (existsOnly: boolean) => void; + clearFilters: () => void; +} + +export const useNavigationStore = create()( + devtools( + (set, get) => ({ + // Initial state + currentIndex: 0, + imageList: [], + selectedFolderPath: null, + navigationMode: 'single', + + // Basic navigation + setCurrentIndex: (index) => { + set({ currentIndex: index }); + }, + + goToNext: () => { + const { currentIndex, imageList } = get(); + let nextIndex = currentIndex + 1; + + // Find the next existing image + while (nextIndex < imageList.length && !imageList[nextIndex].exists) { + nextIndex++; + } + + if (nextIndex < imageList.length) { + set({ currentIndex: nextIndex }); + } + }, + + goToPrevious: () => { + const { currentIndex, imageList } = get(); + let prevIndex = currentIndex - 1; + + // Find the previous existing image + while (prevIndex >= 0 && !imageList[prevIndex].exists) { + prevIndex--; + } + + if (prevIndex >= 0) { + set({ currentIndex: prevIndex }); + } + }, + + goToFirst: () => { + const { imageList } = get(); + let firstExistingIndex = 0; + + // Find the first existing image + while (firstExistingIndex < imageList.length && !imageList[firstExistingIndex].exists) { + firstExistingIndex++; + } + + if (firstExistingIndex < imageList.length) { + set({ currentIndex: firstExistingIndex }); + } + }, + + goToLast: () => { + const { imageList } = get(); + let lastExistingIndex = imageList.length - 1; + + // Find the last existing image + while (lastExistingIndex >= 0 && !imageList[lastExistingIndex].exists) { + lastExistingIndex--; + } + + if (lastExistingIndex >= 0) { + set({ currentIndex: lastExistingIndex }); + } + }, + + goToImage: (index) => { + const { imageList } = get(); + if (index >= 0 && index < imageList.length) { + set({ currentIndex: index }); + } + }, + + // List management + setImageList: (images) => { + set({ + imageList: images, + currentIndex: 0, + }); + }, + + updateImageStatus: (index, status) => { + const { imageList } = get(); + const updatedList = [...imageList]; + if (index >= 0 && index < updatedList.length) { + updatedList[index] = { ...updatedList[index], ...status }; + set({ imageList: updatedList }); + } + }, + + clearImageList: () => { + set({ + imageList: [], + currentIndex: 0, + }); + }, + + setSelectedFolderPath: (path) => { + set({ selectedFolderPath: path }); + if (path) { + set({ navigationMode: 'folder' }); + } + }, + + setNavigationMode: (mode) => { + set({ navigationMode: mode }); + }, + + resetToSingleMode: () => { + // Clear all navigation state + set({ + navigationMode: 'single', + selectedFolderPath: null, + imageList: [], + currentIndex: 0, + }); + }, + + // Filtering (to be implemented based on COCO data integration) + filterByCategory: (categoryIds) => { + // TODO: Implement filtering logic + console.log('Filter by categories:', categoryIds); + }, + + filterByExistence: (existsOnly) => { + // TODO: Implement filtering logic + console.log('Filter by existence:', existsOnly); + }, + + clearFilters: () => { + // TODO: Implement clear filters logic + console.log('Clear filters'); + }, + }), + { + name: 'navigation-store', + } + ) +); diff --git a/src/stores/useSettingsStore.ts b/src/stores/useSettingsStore.ts index e5fe92b..b2967af 100644 --- a/src/stores/useSettingsStore.ts +++ b/src/stores/useSettingsStore.ts @@ -15,7 +15,7 @@ interface DetailSettings { promotedFields: string[]; // Promoted field paths like "option.detection.confidence" } -export type TabType = 'control' | 'info' | 'detail' | 'files'; +export type TabType = 'control' | 'info' | 'detail' | 'files' | 'navigation'; export type PanelSide = 'left' | 'right'; interface PanelLayoutSettings { @@ -113,7 +113,7 @@ const defaultSettings: Omit< promotedFields: [], }, panelLayout: { - leftPanelTabs: ['control', 'files'], + leftPanelTabs: ['control', 'files', 'navigation'], rightPanelTabs: ['info', 'detail'], defaultLeftTab: 'control', defaultRightTab: 'info', From 93fb47762083ba9ce6003b91f46cac29cbcc349d Mon Sep 17 00:00:00 2001 From: tact-software Date: Sun, 1 Jun 2025 07:00:36 +0900 Subject: [PATCH 11/16] =?UTF-8?q?=E6=AF=94=E8=BC=83=E3=83=A2=E3=83=BC?= =?UTF-8?q?=E3=83=89=E3=82=92=E3=83=8A=E3=83=93=E3=82=B2=E3=83=BC=E3=82=B7?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ComparisonDialog/ComparisonDialog.tsx | 84 +++++++++++++------ src/i18n/locales/en.json | 4 +- src/i18n/locales/ja.json | 4 +- src/stores/useAnnotationStore.ts | 75 ++++++++++++++++- 4 files changed, 136 insertions(+), 31 deletions(-) diff --git a/src/components/ComparisonDialog/ComparisonDialog.tsx b/src/components/ComparisonDialog/ComparisonDialog.tsx index 11e3d8b..64a5bd5 100644 --- a/src/components/ComparisonDialog/ComparisonDialog.tsx +++ b/src/components/ComparisonDialog/ComparisonDialog.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { open } from '@tauri-apps/plugin-dialog'; import { invoke } from '@tauri-apps/api/core'; import { useAnnotationStore } from '../../stores/useAnnotationStore'; +import { useNavigationStore } from '../../stores/useNavigationStore'; import { useSettingsStore } from '../../stores/useSettingsStore'; import { toast } from '../../stores/useToastStore'; import { useLoadingStore } from '../../stores/useLoadingStore'; @@ -24,8 +25,10 @@ export const ComparisonDialog = ({ isOpen, onClose }: Props) => { currentImageId, isComparing, comparisonData: currentComparisonData, + originalComparisonData, comparisonSettings: currentComparisonSettings, } = useAnnotationStore(); + const { navigationMode } = useNavigationStore(); const { colors: colorSettings } = useSettingsStore(); const { setLoading } = useLoadingStore(); @@ -61,7 +64,8 @@ export const ComparisonDialog = ({ isOpen, onClose }: Props) => { currentComparisonSettings.gtFileId === 'primary' ? 'current_gt' : 'current_pred', }); - setComparisonData(currentComparisonData); + // Use original comparison data if available, otherwise use current (for backward compatibility) + setComparisonData(originalComparisonData || currentComparisonData); setSelectedImageId(currentImageId); setRoleSelection( currentComparisonSettings.gtFileId === 'primary' ? 'current_gt' : 'current_pred' @@ -132,12 +136,25 @@ export const ComparisonDialog = ({ isOpen, onClose }: Props) => { setSelectedFile(selected); setComparisonData(data); - // Auto-select first image or keep null for dropdown selection + // Handle image selection based on navigation mode if (data.images && data.images.length === 1) { // Only one image, auto-select it setSelectedImageId(data.images[0].id); + } else if (navigationMode === 'folder' && currentImageId) { + // Folder mode: automatically find corresponding image ID + const correspondingImage = data.images.find(img => img.id === currentImageId); + if (correspondingImage) { + setSelectedImageId(correspondingImage.id); + } else { + // No corresponding image found, show error + toast.error( + t('comparison.imageNotFound'), + t('comparison.noCorrespondingImage', { imageId: currentImageId }) + ); + setSelectedImageId(null); + } } else { - // Multiple images, user will select from dropdown + // Single image mode with multiple images: user will select from dropdown setSelectedImageId(null); } @@ -160,32 +177,19 @@ export const ComparisonDialog = ({ isOpen, onClose }: Props) => { console.debug('Starting comparison:', { selectedImageId, + currentImageId, + isComparing, comparisonImages: comparisonData.images.length, comparisonAnnotations: comparisonData.annotations.length, }); - // Filter comparison data to only include annotations for the selected image - console.debug('Before filtering:', { - selectedImageId, - selectedImageIdType: typeof selectedImageId, - allAnnotations: comparisonData.annotations.length, - annotationImageIds: [...new Set(comparisonData.annotations.map((a) => a.image_id))], - annotationImageIdTypes: [ - ...new Set(comparisonData.annotations.map((a) => typeof a.image_id)), - ], - matchingAnnotationsStrict: comparisonData.annotations.filter( - (ann) => ann.image_id === selectedImageId - ).length, - matchingAnnotationsLoose: comparisonData.annotations.filter( - (ann) => ann.image_id == selectedImageId - ).length, - }); - - // If comparing with different image IDs, we need to map the annotations - // to match the current image ID for proper comparison + // When changing settings in comparison mode, we should use the full comparison data + // Only filter for initial comparison setup + const shouldFilterData = !isComparing; + const targetImageId = currentImageId || selectedImageId; - const filteredComparisonData: COCOData = { + const filteredComparisonData: COCOData = shouldFilterData ? { ...comparisonData, // Map annotations to use the target image ID for comparison annotations: comparisonData.annotations @@ -200,6 +204,21 @@ export const ComparisonDialog = ({ isOpen, onClose }: Props) => { ...img, id: targetImageId, // Use the current image ID for comparison })), + } : { + // In comparison mode, just update the current image's annotations + ...comparisonData, + annotations: comparisonData.annotations + .filter((ann) => ann.image_id === currentImageId!) + .map((ann) => ({ + ...ann, + image_id: currentImageId!, + })), + images: comparisonData.images + .filter((img) => img.id === currentImageId!) + .map((img) => ({ + ...img, + id: currentImageId!, + })), }; console.debug('After filtering and ID mapping:', { @@ -231,7 +250,7 @@ export const ComparisonDialog = ({ isOpen, onClose }: Props) => { // Use setTimeout to ensure the loading overlay appears before heavy computation setTimeout(() => { - setStoreComparisonData(filteredComparisonData, settings); + setStoreComparisonData(comparisonData, filteredComparisonData, settings); setLoading(false); onClose(); }, 100); @@ -433,8 +452,8 @@ export const ComparisonDialog = ({ isOpen, onClose }: Props) => {
- {/* Image Selection (for multiple images) */} - {comparisonData && comparisonData.images.length > 1 && ( + {/* Image Selection (for multiple images in single mode only) */} + {comparisonData && comparisonData.images.length > 1 && navigationMode === 'single' && (

{t('comparison.imageSelection')}

@@ -465,6 +484,19 @@ export const ComparisonDialog = ({ isOpen, onClose }: Props) => {
)} + {/* Show selected image info for folder mode */} + {comparisonData && navigationMode === 'folder' && selectedImageId && ( +
+

{t('comparison.selectedImage')}

+
+ {comparisonData.images.find(img => img.id === selectedImageId)?.file_name} (ID: {selectedImageId}) +
+ {t('comparison.selectedImageAnnotations')}:{' '} + {comparisonData.annotations.filter((ann) => ann.image_id === selectedImageId).length} +
+
+ )} + {/* Role Selection */}

{t('comparison.roleSelection')}

diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index bf69166..eba71d4 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -344,7 +344,9 @@ "iouMethodDescription": "Select how to calculate IoU between annotations", "polygonIoUWarning": "Polygon IoU uses a grid-based approximation algorithm, not exact area calculation. The values are approximations balanced for accuracy and performance.", "processing": "Processing comparison...", - "processingSubMessage": "Analyzing annotations and calculating matches" + "processingSubMessage": "Analyzing annotations and calculating matches", + "imageNotFound": "Image not found", + "noCorrespondingImage": "No corresponding image ID {{imageId}} found in comparison file" }, "histogram": { "title": "Distribution Histogram", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 1a53852..5b5040d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -343,7 +343,9 @@ "iouMethodDescription": "アノテーション間のIoU計算方法を選択", "polygonIoUWarning": "ポリゴンIoUはグリッドベースの近似アルゴリズムを使用するため、正確な面積計算ではありません。精度と性能のバランスを考慮した近似値となります。", "processing": "比較処理中...", - "processingSubMessage": "アノテーションを分析してマッチングを計算しています" + "processingSubMessage": "アノテーションを分析してマッチングを計算しています", + "imageNotFound": "画像が見つかりません", + "noCorrespondingImage": "比較ファイルに対応する画像ID {{imageId}} が見つかりません" }, "histogram": { "title": "分布ヒストグラム", diff --git a/src/stores/useAnnotationStore.ts b/src/stores/useAnnotationStore.ts index 487d7b0..5def4f0 100644 --- a/src/stores/useAnnotationStore.ts +++ b/src/stores/useAnnotationStore.ts @@ -21,6 +21,7 @@ interface AnnotationState { // Comparison state comparisonData: COCOData | null; + originalComparisonData: COCOData | null; // Store original unfiltered data isComparing: boolean; comparisonSettings: ComparisonSettings | null; diffResults: Map; @@ -59,12 +60,13 @@ interface AnnotationState { getAnnotationsForCurrentImage: () => COCOAnnotation[]; // Comparison actions - setComparisonData: (data: COCOData, settings: ComparisonSettings) => void; + setComparisonData: (originalData: COCOData, filteredData: COCOData, settings: ComparisonSettings) => void; clearComparison: () => void; calculateDiff: () => void; toggleDiffFilter: (filter: DiffFilter) => void; setDiffFilters: (filters: Set) => void; updateComparisonSettings: (settings: ComparisonSettings) => void; + updateComparisonForCurrentImage: () => void; } export const useAnnotationStore = create((set, get) => ({ @@ -80,6 +82,7 @@ export const useAnnotationStore = create((set, get) => ({ currentSearchIndex: -1, shouldCenterOnSelection: false, comparisonData: null, + originalComparisonData: null, isComparing: false, comparisonSettings: null, diffResults: new Map(), @@ -276,6 +279,8 @@ export const useAnnotationStore = create((set, get) => ({ setCurrentImageId: (id) => { set({ currentImageId: id }); + // If in comparison mode, update comparison annotations for the new image + get().updateComparisonForCurrentImage(); }, getAnnotationsForCurrentImage: () => { @@ -286,9 +291,10 @@ export const useAnnotationStore = create((set, get) => ({ }, // Comparison actions - setComparisonData: (data, settings) => { + setComparisonData: (originalData, filteredData, settings) => { set({ - comparisonData: data, + originalComparisonData: originalData, + comparisonData: filteredData, comparisonSettings: settings, isComparing: true, diffFilters: new Set(['tp-gt', 'tp-pred', 'fp', 'fn']), // Show all result types by default @@ -314,6 +320,7 @@ export const useAnnotationStore = create((set, get) => ({ set({ comparisonData: null, + originalComparisonData: null, comparisonSettings: null, isComparing: false, diffResults: new Map(), @@ -384,4 +391,66 @@ export const useAnnotationStore = create((set, get) => ({ set({ comparisonSettings: settings }); get().calculateDiff(); }, + + updateComparisonForCurrentImage: () => { + const state = get(); + if (!state.isComparing || !state.originalComparisonData || !state.comparisonSettings || !state.currentImageId) { + return; + } + + // Check if there are annotations for the current image ID in ORIGINAL comparison data + const correspondingAnnotations = state.originalComparisonData.annotations.filter(ann => ann.image_id === state.currentImageId); + + console.debug('updateComparisonForCurrentImage:', { + currentImageId: state.currentImageId, + originalComparisonDataHasImages: state.originalComparisonData.images.length, + originalComparisonDataHasAnnotations: state.originalComparisonData.annotations.length, + availableImageIds: [...new Set(state.originalComparisonData.annotations.map(ann => ann.image_id))], + correspondingAnnotationsCount: correspondingAnnotations.length + }); + + if (correspondingAnnotations.length === 0) { + // No corresponding annotations found, show empty comparison data + console.warn('No corresponding annotations found in comparison data for image ID:', state.currentImageId); + + // Import toast to show info + import('../stores/useToastStore').then(({ toast }) => { + toast.info( + '比較情報', + `画像ID ${state.currentImageId} にはペアアノテーションがありません` + ); + }); + + // Set empty comparison data but keep comparison mode active + const emptyComparisonData: COCOData = { + ...state.originalComparisonData, + annotations: [], + images: state.originalComparisonData.images.filter((img) => img.id === state.currentImageId), + }; + + set({ comparisonData: emptyComparisonData }); + get().calculateDiff(); + return; + } + + // Find corresponding image metadata (optional - may not exist in images array) + const correspondingImage = state.originalComparisonData.images.find(img => img.id === state.currentImageId); + + // Filter comparison data to only include annotations for the current image + const filteredComparisonData: COCOData = { + ...state.originalComparisonData, + annotations: correspondingAnnotations.map((ann) => ({ + ...ann, + image_id: state.currentImageId!, // Use the current image ID for comparison + })), + images: correspondingImage ? [{ + ...correspondingImage, + id: state.currentImageId!, // Use the current image ID for comparison + }] : state.originalComparisonData.images.filter((img) => img.id === state.currentImageId), + }; + + // Update comparison data and recalculate diff + set({ comparisonData: filteredComparisonData }); + get().calculateDiff(); + }, })); From 3e8f3186da9bd32be07915242f30a6616818c835 Mon Sep 17 00:00:00 2001 From: tact-software Date: Sun, 1 Jun 2025 07:13:35 +0900 Subject: [PATCH 12/16] =?UTF-8?q?=E3=83=9A=E3=82=A2=E3=82=A2=E3=83=8E?= =?UTF-8?q?=E3=83=86=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3=E3=81=AE=E7=94=BB?= =?UTF-8?q?=E5=83=8FID=E3=81=8C=E5=AD=98=E5=9C=A8=E3=81=97=E3=81=AA?= =?UTF-8?q?=E3=81=84=E5=A0=B4=E5=90=88=E3=81=AE=E3=82=A8=E3=83=A9=E3=83=BC?= =?UTF-8?q?=E3=83=88=E3=83=BC=E3=82=B9=E3=83=88=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NavigationPanel/NavigationPanel.tsx | 9 +- src/stores/useAnnotationStore.ts | 129 +++++++++++------- 2 files changed, 82 insertions(+), 56 deletions(-) diff --git a/src/components/NavigationPanel/NavigationPanel.tsx b/src/components/NavigationPanel/NavigationPanel.tsx index dbddeb0..1cd9045 100644 --- a/src/components/NavigationPanel/NavigationPanel.tsx +++ b/src/components/NavigationPanel/NavigationPanel.tsx @@ -101,9 +101,12 @@ export const NavigationPanel: React.FC = () => { // Update current image ID in annotation store for folder mode if (currentAnnotation && navigationMode === 'folder') { - setCurrentImageId(image.id); - // Clear selected annotations when switching images - clearSelection(); + // Only update if ID actually changed + if (currentAnnotation.currentImageId !== image.id) { + setCurrentImageId(image.id); + // Clear selected annotations when switching images + clearSelection(); + } } // End loading after image is fully loaded and displayed diff --git a/src/stores/useAnnotationStore.ts b/src/stores/useAnnotationStore.ts index 5def4f0..bacd7c3 100644 --- a/src/stores/useAnnotationStore.ts +++ b/src/stores/useAnnotationStore.ts @@ -392,65 +392,88 @@ export const useAnnotationStore = create((set, get) => ({ get().calculateDiff(); }, - updateComparisonForCurrentImage: () => { - const state = get(); - if (!state.isComparing || !state.originalComparisonData || !state.comparisonSettings || !state.currentImageId) { - return; - } + updateComparisonForCurrentImage: (() => { + let lastToastImageId: number | null = null; + let toastTimeout: NodeJS.Timeout | null = null; + + return () => { + const state = get(); + if (!state.isComparing || !state.originalComparisonData || !state.comparisonSettings || !state.currentImageId) { + return; + } - // Check if there are annotations for the current image ID in ORIGINAL comparison data - const correspondingAnnotations = state.originalComparisonData.annotations.filter(ann => ann.image_id === state.currentImageId); - - console.debug('updateComparisonForCurrentImage:', { - currentImageId: state.currentImageId, - originalComparisonDataHasImages: state.originalComparisonData.images.length, - originalComparisonDataHasAnnotations: state.originalComparisonData.annotations.length, - availableImageIds: [...new Set(state.originalComparisonData.annotations.map(ann => ann.image_id))], - correspondingAnnotationsCount: correspondingAnnotations.length - }); - - if (correspondingAnnotations.length === 0) { - // No corresponding annotations found, show empty comparison data - console.warn('No corresponding annotations found in comparison data for image ID:', state.currentImageId); + // Check if there are annotations for the current image ID in ORIGINAL comparison data + const correspondingAnnotations = state.originalComparisonData.annotations.filter(ann => ann.image_id === state.currentImageId); - // Import toast to show info - import('../stores/useToastStore').then(({ toast }) => { - toast.info( - '比較情報', - `画像ID ${state.currentImageId} にはペアアノテーションがありません` - ); + console.debug('updateComparisonForCurrentImage:', { + currentImageId: state.currentImageId, + originalComparisonDataHasImages: state.originalComparisonData.images.length, + originalComparisonDataHasAnnotations: state.originalComparisonData.annotations.length, + availableImageIds: [...new Set(state.originalComparisonData.annotations.map(ann => ann.image_id))], + correspondingAnnotationsCount: correspondingAnnotations.length }); - // Set empty comparison data but keep comparison mode active - const emptyComparisonData: COCOData = { + if (correspondingAnnotations.length === 0) { + // No corresponding annotations found, show empty comparison data + console.warn('No corresponding annotations found in comparison data for image ID:', state.currentImageId); + + // Prevent duplicate toasts for the same image ID + if (lastToastImageId !== state.currentImageId) { + lastToastImageId = state.currentImageId; + + // Clear any pending toast + if (toastTimeout) { + clearTimeout(toastTimeout); + } + + // Import toast to show info + import('../stores/useToastStore').then(({ toast }) => { + toast.info( + '比較情報', + `画像ID ${state.currentImageId} にはペアアノテーションがありません` + ); + }); + + // Reset toast ID after a delay + toastTimeout = setTimeout(() => { + lastToastImageId = null; + }, 1000); + } + + // Set empty comparison data but keep comparison mode active + const emptyComparisonData: COCOData = { + ...state.originalComparisonData, + annotations: [], + images: state.originalComparisonData.images.filter((img) => img.id === state.currentImageId), + }; + + set({ comparisonData: emptyComparisonData }); + get().calculateDiff(); + return; + } else { + // Reset last toast ID when annotations are found + lastToastImageId = null; + } + + // Find corresponding image metadata (optional - may not exist in images array) + const correspondingImage = state.originalComparisonData.images.find(img => img.id === state.currentImageId); + + // Filter comparison data to only include annotations for the current image + const filteredComparisonData: COCOData = { ...state.originalComparisonData, - annotations: [], - images: state.originalComparisonData.images.filter((img) => img.id === state.currentImageId), + annotations: correspondingAnnotations.map((ann) => ({ + ...ann, + image_id: state.currentImageId!, // Use the current image ID for comparison + })), + images: correspondingImage ? [{ + ...correspondingImage, + id: state.currentImageId!, // Use the current image ID for comparison + }] : state.originalComparisonData.images.filter((img) => img.id === state.currentImageId), }; - - set({ comparisonData: emptyComparisonData }); - get().calculateDiff(); - return; - } - // Find corresponding image metadata (optional - may not exist in images array) - const correspondingImage = state.originalComparisonData.images.find(img => img.id === state.currentImageId); - - // Filter comparison data to only include annotations for the current image - const filteredComparisonData: COCOData = { - ...state.originalComparisonData, - annotations: correspondingAnnotations.map((ann) => ({ - ...ann, - image_id: state.currentImageId!, // Use the current image ID for comparison - })), - images: correspondingImage ? [{ - ...correspondingImage, - id: state.currentImageId!, // Use the current image ID for comparison - }] : state.originalComparisonData.images.filter((img) => img.id === state.currentImageId), + // Update comparison data and recalculate diff + set({ comparisonData: filteredComparisonData }); + get().calculateDiff(); }; - - // Update comparison data and recalculate diff - set({ comparisonData: filteredComparisonData }); - get().calculateDiff(); - }, + })(), })); From 265f7a5aed1bde3713801ee160a89b298b6e6f72 Mon Sep 17 00:00:00 2001 From: tact-software Date: Sun, 1 Jun 2025 07:16:43 +0900 Subject: [PATCH 13/16] =?UTF-8?q?=E3=83=95=E3=82=A9=E3=83=BC=E3=83=9E?= =?UTF-8?q?=E3=83=83=E3=83=88=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ComparisonDialog/ComparisonDialog.tsx | 71 ++++++++++--------- src/stores/useAnnotationStore.ts | 64 +++++++++++------ 2 files changed, 81 insertions(+), 54 deletions(-) diff --git a/src/components/ComparisonDialog/ComparisonDialog.tsx b/src/components/ComparisonDialog/ComparisonDialog.tsx index 64a5bd5..4ccc3f7 100644 --- a/src/components/ComparisonDialog/ComparisonDialog.tsx +++ b/src/components/ComparisonDialog/ComparisonDialog.tsx @@ -142,7 +142,7 @@ export const ComparisonDialog = ({ isOpen, onClose }: Props) => { setSelectedImageId(data.images[0].id); } else if (navigationMode === 'folder' && currentImageId) { // Folder mode: automatically find corresponding image ID - const correspondingImage = data.images.find(img => img.id === currentImageId); + const correspondingImage = data.images.find((img) => img.id === currentImageId); if (correspondingImage) { setSelectedImageId(correspondingImage.id); } else { @@ -186,40 +186,42 @@ export const ComparisonDialog = ({ isOpen, onClose }: Props) => { // When changing settings in comparison mode, we should use the full comparison data // Only filter for initial comparison setup const shouldFilterData = !isComparing; - + const targetImageId = currentImageId || selectedImageId; - const filteredComparisonData: COCOData = shouldFilterData ? { - ...comparisonData, - // Map annotations to use the target image ID for comparison - annotations: comparisonData.annotations - .filter((ann) => ann.image_id === selectedImageId) - .map((ann) => ({ - ...ann, - image_id: targetImageId, // Use the current image ID for comparison - })), - images: comparisonData.images - .filter((img) => img.id === selectedImageId) - .map((img) => ({ - ...img, - id: targetImageId, // Use the current image ID for comparison - })), - } : { - // In comparison mode, just update the current image's annotations - ...comparisonData, - annotations: comparisonData.annotations - .filter((ann) => ann.image_id === currentImageId!) - .map((ann) => ({ - ...ann, - image_id: currentImageId!, - })), - images: comparisonData.images - .filter((img) => img.id === currentImageId!) - .map((img) => ({ - ...img, - id: currentImageId!, - })), - }; + const filteredComparisonData: COCOData = shouldFilterData + ? { + ...comparisonData, + // Map annotations to use the target image ID for comparison + annotations: comparisonData.annotations + .filter((ann) => ann.image_id === selectedImageId) + .map((ann) => ({ + ...ann, + image_id: targetImageId, // Use the current image ID for comparison + })), + images: comparisonData.images + .filter((img) => img.id === selectedImageId) + .map((img) => ({ + ...img, + id: targetImageId, // Use the current image ID for comparison + })), + } + : { + // In comparison mode, just update the current image's annotations + ...comparisonData, + annotations: comparisonData.annotations + .filter((ann) => ann.image_id === currentImageId!) + .map((ann) => ({ + ...ann, + image_id: currentImageId!, + })), + images: comparisonData.images + .filter((img) => img.id === currentImageId!) + .map((img) => ({ + ...img, + id: currentImageId!, + })), + }; console.debug('After filtering and ID mapping:', { originalImageId: selectedImageId, @@ -489,7 +491,8 @@ export const ComparisonDialog = ({ isOpen, onClose }: Props) => {

{t('comparison.selectedImage')}

- {comparisonData.images.find(img => img.id === selectedImageId)?.file_name} (ID: {selectedImageId}) + {comparisonData.images.find((img) => img.id === selectedImageId)?.file_name} (ID:{' '} + {selectedImageId})
{t('comparison.selectedImageAnnotations')}:{' '} {comparisonData.annotations.filter((ann) => ann.image_id === selectedImageId).length} diff --git a/src/stores/useAnnotationStore.ts b/src/stores/useAnnotationStore.ts index bacd7c3..e805152 100644 --- a/src/stores/useAnnotationStore.ts +++ b/src/stores/useAnnotationStore.ts @@ -60,7 +60,11 @@ interface AnnotationState { getAnnotationsForCurrentImage: () => COCOAnnotation[]; // Comparison actions - setComparisonData: (originalData: COCOData, filteredData: COCOData, settings: ComparisonSettings) => void; + setComparisonData: ( + originalData: COCOData, + filteredData: COCOData, + settings: ComparisonSettings + ) => void; clearComparison: () => void; calculateDiff: () => void; toggleDiffFilter: (filter: DiffFilter) => void; @@ -398,34 +402,46 @@ export const useAnnotationStore = create((set, get) => ({ return () => { const state = get(); - if (!state.isComparing || !state.originalComparisonData || !state.comparisonSettings || !state.currentImageId) { + if ( + !state.isComparing || + !state.originalComparisonData || + !state.comparisonSettings || + !state.currentImageId + ) { return; } // Check if there are annotations for the current image ID in ORIGINAL comparison data - const correspondingAnnotations = state.originalComparisonData.annotations.filter(ann => ann.image_id === state.currentImageId); - + const correspondingAnnotations = state.originalComparisonData.annotations.filter( + (ann) => ann.image_id === state.currentImageId + ); + console.debug('updateComparisonForCurrentImage:', { currentImageId: state.currentImageId, originalComparisonDataHasImages: state.originalComparisonData.images.length, originalComparisonDataHasAnnotations: state.originalComparisonData.annotations.length, - availableImageIds: [...new Set(state.originalComparisonData.annotations.map(ann => ann.image_id))], - correspondingAnnotationsCount: correspondingAnnotations.length + availableImageIds: [ + ...new Set(state.originalComparisonData.annotations.map((ann) => ann.image_id)), + ], + correspondingAnnotationsCount: correspondingAnnotations.length, }); - + if (correspondingAnnotations.length === 0) { // No corresponding annotations found, show empty comparison data - console.warn('No corresponding annotations found in comparison data for image ID:', state.currentImageId); - + console.warn( + 'No corresponding annotations found in comparison data for image ID:', + state.currentImageId + ); + // Prevent duplicate toasts for the same image ID if (lastToastImageId !== state.currentImageId) { lastToastImageId = state.currentImageId; - + // Clear any pending toast if (toastTimeout) { clearTimeout(toastTimeout); } - + // Import toast to show info import('../stores/useToastStore').then(({ toast }) => { toast.info( @@ -433,20 +449,22 @@ export const useAnnotationStore = create((set, get) => ({ `画像ID ${state.currentImageId} にはペアアノテーションがありません` ); }); - + // Reset toast ID after a delay toastTimeout = setTimeout(() => { lastToastImageId = null; }, 1000); } - + // Set empty comparison data but keep comparison mode active const emptyComparisonData: COCOData = { ...state.originalComparisonData, annotations: [], - images: state.originalComparisonData.images.filter((img) => img.id === state.currentImageId), + images: state.originalComparisonData.images.filter( + (img) => img.id === state.currentImageId + ), }; - + set({ comparisonData: emptyComparisonData }); get().calculateDiff(); return; @@ -456,7 +474,9 @@ export const useAnnotationStore = create((set, get) => ({ } // Find corresponding image metadata (optional - may not exist in images array) - const correspondingImage = state.originalComparisonData.images.find(img => img.id === state.currentImageId); + const correspondingImage = state.originalComparisonData.images.find( + (img) => img.id === state.currentImageId + ); // Filter comparison data to only include annotations for the current image const filteredComparisonData: COCOData = { @@ -465,10 +485,14 @@ export const useAnnotationStore = create((set, get) => ({ ...ann, image_id: state.currentImageId!, // Use the current image ID for comparison })), - images: correspondingImage ? [{ - ...correspondingImage, - id: state.currentImageId!, // Use the current image ID for comparison - }] : state.originalComparisonData.images.filter((img) => img.id === state.currentImageId), + images: correspondingImage + ? [ + { + ...correspondingImage, + id: state.currentImageId!, // Use the current image ID for comparison + }, + ] + : state.originalComparisonData.images.filter((img) => img.id === state.currentImageId), }; // Update comparison data and recalculate diff From 5dab1113829ab0b262d8ea2a2e061a2b1ece48a9 Mon Sep 17 00:00:00 2001 From: tact-software Date: Sun, 1 Jun 2025 07:17:22 +0900 Subject: [PATCH 14/16] =?UTF-8?q?=E3=83=89=E3=82=AD=E3=83=A5=E3=83=A1?= =?UTF-8?q?=E3=83=B3=E3=83=88=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.en.md | 14 +++ CHANGELOG.md | 14 +++ docs/dev/feature-multi-image-navigation.md | 130 +++++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 docs/dev/feature-multi-image-navigation.md diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index ed3d6f1..30f6437 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -11,6 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Multi-Image Navigation** + - **Navigation Panel**: New panel for easy navigation between images in a folder + - **Image List Display**: Image list and status indicators + - **ID Jump Feature**: Direct navigation to specific image by entering image ID + - **Smart Navigation**: Automatically skip non-existent images + - **View State Preservation**: Maintain zoom and pan state when switching images + - **Comparison Mode Support**: Navigate between images while comparing, with dynamic pair annotation updates + - **Keyboard Shortcuts**: Arrow keys for previous/next image navigation - **Annotation Statistical Analysis** - **Histogram Analysis**: 5 distribution types (width, height, area, polygon area, aspect ratio) - **Heatmap Analysis**: 4 2D distributions (width×height, center coordinates, area×aspect ratio, etc.) @@ -45,12 +53,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved zoom buttons to zoom relative to viewport center - Auto-adjust view mode for single image datasets in statistics dialog +- Enhanced comparison mode data management with original data preservation +- Updated panel settings UI to place NavigationPanel as an independent tab ### Fixed - Fixed infinite loop issue after category mapping - Fixed comparison results not showing for single image datasets - Enabled comparison between datasets with different image IDs +- Fixed ReferenceError during NavigationPanel initialization +- Fixed zoom/pan state reset when switching images +- Fixed annotation data loss when changing comparison settings +- Prevented unnecessary image ID reset when switching tabs ### Removed diff --git a/CHANGELOG.md b/CHANGELOG.md index 91182e1..26447c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ ### 追加 +- **マルチ画像ナビゲーション機能** + - **NavigationPanel**: フォルダ内の画像間を簡単に移動できる新しいパネル + - **画像リスト表示**: 画像一覧とステータス表示 + - **IDジャンプ機能**: 画像IDを直接入力して特定の画像へ移動 + - **スマートナビゲーション**: 存在しない画像を自動的にスキップ + - **ビュー状態保持**: 画像切り替え時もズーム・パン状態を維持 + - **比較モード対応**: 比較中でも画像間を移動でき、ペアアノテーションが動的に更新 + - **キーボードショートカット**: 矢印キーで前後の画像へ移動 - **アノテーション統計分析機能** - **ヒストグラム分析**: 5つの分布タイプ(幅、高さ、面積、ポリゴン面積、アスペクト比) - **ヒートマップ分析**: 4つの2次元分布(幅×高さ、中心座標、面積×アスペクト比等) @@ -45,12 +53,18 @@ - ズームボタンがビューポート中心を基準にズームするよう改善 - 統計ダイアログで単一画像データセットのビューモードを自動調整 +- 比較モードでのデータ管理を改善し、元データを保持する仕組みを追加 +- パネル設定のUIを更新し、NavigationPanelを独立したタブに配置 ### 修正 - カテゴリマッピング後の無限ループ問題を修正 - 単一画像データセットで比較結果が表示されない問題を修正 - 異なる画像IDを持つデータセット間の比較を可能に +- NavigationPanelの初期化時のReferenceErrorを修正 +- 画像切り替え時のズーム・パン状態リセット問題を修正 +- 比較設定変更時のアノテーションデータ損失を修正 +- タブ切り替え時の不要な画像IDリセットを防止 ### 削除 diff --git a/docs/dev/feature-multi-image-navigation.md b/docs/dev/feature-multi-image-navigation.md new file mode 100644 index 0000000..2b5922f --- /dev/null +++ b/docs/dev/feature-multi-image-navigation.md @@ -0,0 +1,130 @@ +# 機能: feature/multi-image-navigation + +## 概要 + +複数画像間のナビゲーション機能を実装し、フォルダ内の画像を簡単に切り替えられるようにしました。また、比較モード中でも画像間を移動でき、各画像に対応するペアアノテーションが動的に更新されます。 + +## 実装期間 + +- ブランチ: `feature/multi-image-navigation` +- バージョン: v1.1.0(予定) +- コミット数: 約4 + +## 主要機能 + +### 1. NavigationPanel コンポーネント + +新しいナビゲーションパネルを実装し、フォルダ内の画像間を移動できるようにしました。 + +#### 機能 + +- 前へ/次へボタンによる画像切り替え +- IDジャンプ機能(画像IDを直接入力して移動) +- 存在しない画像のスキップ機能 +- フォルダモードと単一画像モードの切り替え +- 画像切り替え時のズーム/パン状態の保持 + +#### 技術詳細 + +- **状態管理**: Zustand を使用した `useNavigationStore` で画像リストと現在のインデックスを管理 +- **非同期処理**: Tauri の `scan_folder` コマンドで画像ファイルを効率的にスキャン +- **パフォーマンス**: 画像切り替え時の `preserveViewState` フラグで不要な再レンダリングを防止 +- **アクセシビリティ**: キーボードショートカット(矢印キー)による操作をサポート + +### 2. 比較モードの拡張 + +比較モード中でも画像間のナビゲーションが可能になり、各画像に対応するペアアノテーションが動的に更新されます。 + +#### 機能 + +- フォルダモードでは自動的に対応する画像IDのアノテーションを使用 +- 画像切り替え時にペアアノテーションを動的に更新 +- 対応するアノテーションがない画像では情報メッセージを表示 +- 比較設定変更時も全画像のアノテーションを保持 + +#### 改善点 + +- 元の比較データ(`originalComparisonData`)を保持することで、画像切り替え時の再フィルタリングが可能に +- 比較モード中でもナビゲーション機能が使えるため、複数画像の比較が効率的に + +### 3. UI/UX の改善 + +ナビゲーション関連のUIを改善し、より直感的な操作を実現しました。 + +#### 機能 + +- ナビゲーションパネルの独立したタブ化 +- 画像切り替え時のローディングインジケーター +- 画像存在確認とエラーハンドリング +- レスポンシブデザイン対応 + +#### 改善点 + +- モバイルビューでのナビゲーションパネルの最適化 +- 画像切り替え時の選択アノテーションのクリア +- パネル設定での重複表示の解消 + +## ファイル変更 + +### 新規ファイル + +- `src/components/NavigationPanel/NavigationPanel.tsx` - ナビゲーションパネルのメインコンポーネント +- `src/components/NavigationPanel/NavigationPanel.css` - ナビゲーションパネルのスタイル定義 +- `src/components/NavigationPanel/index.tsx` - エクスポート用インデックス +- `src/stores/useNavigationStore.ts` - ナビゲーション状態管理ストア + +### 変更されたファイル + +主な変更は以下のファイルに加えられました: + +- `src-tauri/src/commands/mod.rs` - `scan_folder` コマンドの追加(画像ファイルスキャン機能) +- `src/App.tsx` - NavigationPanel の統合とタブ構造の更新 +- `src/components/ComparisonDialog/ComparisonDialog.tsx` - ナビゲーションモード対応と元データ保持機能 +- `src/stores/useAnnotationStore.ts` - `originalComparisonData` の追加と `updateComparisonForCurrentImage` の実装 +- `src/stores/useImageStore.ts` - `preserveViewState` フラグの追加 +- `src/components/ImageViewer/ImageViewer.tsx` - 画像切り替え時のビュー状態保持機能 +- `src/i18n/locales/*.json` - ナビゲーション関連の翻訳文字列追加 + +## バグ修正 + +1. **ReferenceError の修正** + - 関数定義の順序を修正し、NavigationPanel の初期化エラーを解決 + +2. **画像ID リセット問題** + - タブ切り替え時の不要な画像ID リセットを防止 + +3. **ズーム/パン状態のリセット** + - 画像切り替え時に `preserveViewState` フラグを使用してビュー状態を保持 + +4. **比較モードでのデータ損失** + - 元データを保持することで、設定変更時のアノテーション損失を防止 + +5. **重複タブ表示** + - パネル設定での Info タブの重複を解消 + +## テスト方法 + +この機能は以下の方法でテストできます: + +1. フォルダを開いて複数の画像を含むアノテーションファイルを読み込む +2. ナビゲーションタブで矢印ボタンまたはキーボードの矢印キーで画像を切り替える +3. IDジャンプ機能で特定の画像IDに直接移動する +4. 比較モードを開始し、画像を切り替えてペアアノテーションが更新されることを確認 +5. 比較設定を変更後、他の画像に移動してもアノテーションが表示されることを確認 + +## 今後の改善点 + +1. サムネイルのキャッシュ機能の実装 +2. 画像のプリロード機能による切り替えの高速化 +3. フィルタリング機能(カテゴリや画像サイズでの絞り込み) +4. 画像グリッドビューの追加 +5. キーボードショートカットのカスタマイズ機能 + +## 依存関係 + +新しい外部依存関係は追加されていません。既存のライブラリを使用: + +- Zustand(状態管理) +- React(UIコンポーネント) +- Tauri API(ファイルシステムアクセス) +- i18next(国際化対応) \ No newline at end of file From b64e3f4930d8ca29a2e5c128d7c4fe1ad7d7c8b2 Mon Sep 17 00:00:00 2001 From: tact-software Date: Sun, 1 Jun 2025 07:32:06 +0900 Subject: [PATCH 15/16] =?UTF-8?q?=E4=B8=8D=E8=B6=B3=E3=83=91=E3=83=83?= =?UTF-8?q?=E3=82=B1=E3=83=BC=E3=82=B8=E3=81=AE=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bun.lock | 3 +++ package.json | 1 + 2 files changed, 4 insertions(+) diff --git a/bun.lock b/bun.lock index 724b10a..f28e873 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "@tauri-apps/plugin-opener": "^2", "i18next": "^25.2.1", "konva": "^9.3.20", + "lucide-react": "^0.511.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^15.5.2", @@ -799,6 +800,8 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lucide-react": ["lucide-react@0.511.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], diff --git a/package.json b/package.json index 17e3599..ebcef2c 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@tauri-apps/plugin-opener": "^2", "i18next": "^25.2.1", "konva": "^9.3.20", + "lucide-react": "^0.511.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^15.5.2", From 882dbc3c9901300d169b2c71e3df6422058341ea Mon Sep 17 00:00:00 2001 From: tact-software Date: Fri, 20 Jun 2025 22:29:30 +0900 Subject: [PATCH 16/16] chore: bump version to 1.1.0 --- CHANGELOG.en.md | 5 ++++- CHANGELOG.md | 5 ++++- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index 30f6437..9ef9e5b 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.0] - 2025-06-20 + ### Added - **Multi-Image Navigation** @@ -107,5 +109,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - State management with Zustand - Cross-platform support (Windows, macOS, Linux) -[Unreleased]: https://github.com/tact-software/coav/compare/v1.0.0...HEAD +[Unreleased]: https://github.com/tact-software/coav/compare/v1.1.0...HEAD +[1.1.0]: https://github.com/tact-software/coav/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/tact-software/coav/releases/tag/v1.0.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 26447c9..d020901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ ## [未リリース] +## [1.1.0] - 2025-06-20 + ### 追加 - **マルチ画像ナビゲーション機能** @@ -107,5 +109,6 @@ - Zustandによる状態管理 - クロスプラットフォーム対応(Windows、macOS、Linux) -[未リリース]: https://github.com/tact-software/coav/compare/v1.0.0...HEAD +[未リリース]: https://github.com/tact-software/coav/compare/v1.1.0...HEAD +[1.1.0]: https://github.com/tact-software/coav/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/tact-software/coav/releases/tag/v1.0.0 diff --git a/package.json b/package.json index ebcef2c..b2d09ef 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coav", "private": true, - "version": "1.0.0", + "version": "1.1.0", "description": "COCO Annotation Viewer - A modern desktop application for viewing and analyzing COCO dataset annotations", "license": "MIT", "author": "COAV Contributors", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2d4a14d..89cb3c0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -619,7 +619,7 @@ dependencies = [ [[package]] name = "coav" -version = "1.0.0" +version = "1.1.0" dependencies = [ "chrono", "image", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 55d9f1b..1d00d68 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "coav" -version = "1.0.0" +version = "1.1.0" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0955f27..1672beb 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "coav", - "version": "1.0.0", + "version": "1.1.0", "identifier": "com.coav.app", "build": { "beforeDevCommand": "bun run dev",