- )}
-
- {/* Category Distribution */}
- {viewStats && viewStats.categoryDistribution.length > 0 && (
-
+ )}
- {/* 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('infoPanel.currentImage') : t('infoPanel.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')}
-
+
+
+
-
+
{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..4bbc9ac 100644
--- a/src/hooks/useMenuEvents.ts
+++ b/src/hooks/useMenuEvents.ts
@@ -8,7 +8,10 @@ export const useMenuEvents = (
onGenerateSample: () => void,
onExportAnnotations: () => void,
onShowStatistics: () => void,
- onShowSettings: () => void
+ onShowSettings: () => void,
+ onShowComparison?: () => void,
+ onShowHistogram?: () => void,
+ onShowHeatmap?: () => void
) => {
const { zoomIn, zoomOut, resetView, fitToWindow } = useImageStore();
const { showAllCategories, hideAllCategories, clearCocoData } = useAnnotationStore();
@@ -40,6 +43,21 @@ 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));
+ }
+
+ // Add histogram dialog listener if handler provided
+ 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();
@@ -55,6 +73,9 @@ export const useMenuEvents = (
onExportAnnotations,
onShowStatistics,
onShowSettings,
+ onShowComparison,
+ onShowHistogram,
+ onShowHeatmap,
zoomIn,
zoomOut,
resetView,
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index f82f481..eba71d4 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -11,7 +11,8 @@
"copiedToClipboard": "Copied to clipboard",
"id": "ID",
"yes": "Yes",
- "no": "No"
+ "no": "No",
+ "cancel": "Cancel"
},
"menu": {
"file": "File",
@@ -19,6 +20,7 @@
"help": "Help",
"openImage": "Open Image",
"openAnnotations": "Open Annotations",
+ "openFolder": "Open Folder",
"exportAnnotations": "Export Annotations",
"generateSample": "Generate Sample Data",
"settings": "Settings",
@@ -46,7 +48,17 @@
"annotations": "Annotations",
"detailDisplay": "Detail Display",
"selectAll": "Select All",
- "selectNone": "Select None"
+ "selectNone": "Select None",
+ "comparison": "Comparison",
+ "openComparisonFile": "Open Comparison File",
+ "comparingFiles": "Comparing Files",
+ "endComparison": "End Comparison",
+ "changeComparisonSettings": "Change Settings",
+ "truePositiveGt": "TP-GroundTruth",
+ "truePositivePred": "TP-Prediction",
+ "falsePositive": "FP",
+ "falseNegative": "FN",
+ "comparisonFilterInfo": "Category filters are disabled during comparison mode"
},
"search": {
"clear": "Clear search",
@@ -67,6 +79,8 @@
"visible": "Visible",
"selected": "Selected",
"categoryDistribution": "Category Distribution",
+ "comparisonResults": "Comparison Results",
+ "comparisonMetrics": "Comparison Metrics",
"sizeAnalysis": "Size Analysis",
"min": "Min",
"max": "Max",
@@ -112,7 +126,17 @@
"otherFields": "Other Fields",
"extendedFields": "Extended Fields",
"multipleSelected": "{{count}} annotations selected",
- "annotation": "Annotation"
+ "annotation": "Annotation",
+ "comparisonResult": "Comparison Result",
+ "type": "Type",
+ "role": "Role",
+ "groundTruth": "Ground Truth",
+ "prediction": "Prediction",
+ "matches": "Matches",
+ "noMatchingPartner": "No matching partner",
+ "groundTruthAnnotation": "Ground Truth Annotation",
+ "predictionAnnotation": "Prediction Annotation",
+ "belowThreshold": "below threshold"
},
"empty": {
"noImageLoaded": "No Image Loaded",
@@ -123,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",
@@ -154,6 +180,20 @@
"includeLicenses": "Include licenses in annotations",
"includeOption": "Include custom option fields for each annotation",
"includeMultiPolygon": "Include multi-polygon annotations (random secondary polygons)",
+ "generating": "Generating sample data...",
+ "generatingSubMessage": "Creating {{images}} images and {{annotations}} annotations",
+ "includePairJson": "Generate pair JSON (for comparison testing)",
+ "changePairCategoryNames": "Change category names in pair file",
+ "changePairCategoryNamesDescription": "When enabled, appends '2' to category names in the pair file (e.g., rectangle → rectangle2)",
+ "maxPairMatches": "Max pair matches per annotation",
+ "maxPairMatchesDescription": "Number of matching pairs for each annotation (1-5)",
+ "distributionSettings": "Distribution Settings",
+ "distributionDescription": "Adjust the distribution of annotation types in the pair JSON. Values will automatically adjust to total 100%.",
+ "perfectMatch": "Perfect Match",
+ "partialMatch": "Partial Overlap",
+ "noMatch": "No Match (FN)",
+ "additional": "Additional (FP)",
+ "total": "Total",
"extendedFields": "Extended Fields",
"generate": "Generate",
"cancel": "Cancel",
@@ -165,6 +205,7 @@
"objectsWithAspectRatio": "Objects with 1:1 aspect ratio, max size: {{size}}px",
"availableShapes": "Available shapes: rectangle, triangle, star, pentagon, hexagon, diamond, cross, arrow, house, octagon",
"outputFiles": "Output files: {{name}}-image.png and {{name}}-annotation.json",
+ "pairJsonOutput": "Pair JSON output: {{name}}-pair.json (with random modifications)",
"generationFailed": "Failed to generate sample data"
},
"statistics": {
@@ -184,7 +225,17 @@
"average": "Average",
"close": "Close",
"copy": "Copy",
- "annotationStatistics": "Annotation Statistics"
+ "annotationStatistics": "Annotation Statistics",
+ "currentImage": "Current Image",
+ "allImages": "All Images",
+ "comparisonMetrics": "Comparison Evaluation Metrics",
+ "truePositives": "True Positives (TP)",
+ "falsePositives": "False Positives (FP)",
+ "falseNegatives": "False Negatives (FN)",
+ "precision": "Precision",
+ "recall": "Recall",
+ "f1Score": "F1-score",
+ "comparisonModeOnly": "Comparison metrics are only available in comparison mode"
},
"settings": {
"title": "Settings",
@@ -224,7 +275,13 @@
"resetToDefault": "Reset to Default",
"confirmReset": "Reset all category colors to default?",
"transparent": "Transparent",
- "opaque": "Opaque"
+ "opaque": "Opaque",
+ "comparisonColors": "Comparison Colors",
+ "groundTruth": "Ground Truth",
+ "prediction": "Prediction",
+ "truePositive": "True Positive",
+ "falsePositive": "False Positive",
+ "falseNegative": "False Negative"
},
"imageSelection": {
"title": "Select Image",
@@ -235,5 +292,147 @@
},
"files": {
"recentFiles": "Files"
+ },
+ "comparison": {
+ "title": "Comparison Settings",
+ "fileSelection": "Comparison File",
+ "selectFile": "Select File...",
+ "usingCurrentFile": "Using current comparison file",
+ "fileInfo": "{{images}} images, {{annotations}} annotations",
+ "roleSelection": "File Roles",
+ "currentAsGT": "Current file = Ground Truth, Comparison file = Prediction",
+ "currentAsPred": "Current file = Prediction, Comparison file = Ground Truth",
+ "categoryMapping": "Category Mapping",
+ "categoryMappingDescription": "Set comparison targets for each category. Categories without mapping will be excluded from evaluation.",
+ "currentFile": "Current File",
+ "comparisonFile": "Comparison File",
+ "noMapping": "No mapping",
+ "addMapping": "Add mapping",
+ "removeMapping": "Remove mapping",
+ "add": "Add",
+ "selectCategory": "Select Category",
+ "allCategoriesMapped": "All categories mapped",
+ "noAvailableCategories": "No available categories",
+ "unassigned": "Unassigned",
+ "unassignedComparison": "Unassigned (Comparison File)",
+ "availableCategories": "Available Categories (Comparison File)",
+ "clickToAssign": "Click to assign",
+ "matchingSettings": "Matching Settings",
+ "iouThreshold": "IoU Threshold",
+ "maxMatchesPerAnnotation": "Max Matches per Annotation",
+ "startComparison": "Start Comparison",
+ "fileLoadError": "Failed to load comparison file",
+ "selectedImage": "Selected Image",
+ "imageSelection": "Image Selection",
+ "selectImage": "Please select an image",
+ "imageIdMappingNote": "* Annotations from the selected image will be compared with the current image",
+ "selectedImageAnnotations": "Annotations in selected image",
+ "currentFileInfo": "Current File",
+ "currentImage": "Current Image",
+ "colorSettings": "Color Settings",
+ "groundTruth": "Ground Truth",
+ "prediction": "Prediction",
+ "truePositive": "True Positive",
+ "falsePositive": "False Positive",
+ "falseNegative": "False Negative",
+ "displaySettings": "Display Settings",
+ "showBoundingBoxes": "Show Bounding Boxes",
+ "showLabels": "Show Labels",
+ "iouMethod": "IoU Calculation Method",
+ "bboxIoU": "Bounding Box",
+ "polygonIoU": "Polygon (Segmentation)",
+ "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",
+ "imageNotFound": "Image not found",
+ "noCorrespondingImage": "No corresponding image ID {{imageId}} found in comparison file"
+ },
+ "histogram": {
+ "title": "Distribution Histogram",
+ "noData": "No annotation data available",
+ "export": "Export CSV",
+ "copy": "Copy to Clipboard",
+ "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",
+ "viewMode": "View Mode",
+ "currentImage": "Current Image",
+ "allImages": "All Images",
+ "q1": "Q1 (25th percentile)",
+ "q3": "Q3 (75th percentile)",
+ "iqr": "IQR (Interquartile Range)",
+ "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"
+ },
+ "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 d59e865..5b5040d 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -11,7 +11,8 @@
"copiedToClipboard": "クリップボードにコピーしました",
"id": "ID",
"yes": "はい",
- "no": "いいえ"
+ "no": "いいえ",
+ "cancel": "キャンセル"
},
"menu": {
"file": "ファイル",
@@ -19,6 +20,7 @@
"help": "ヘルプ",
"openImage": "画像を開く",
"openAnnotations": "アノテーションを開く",
+ "openFolder": "フォルダを開く",
"exportAnnotations": "アノテーションをエクスポート",
"generateSample": "サンプルデータを生成",
"settings": "設定",
@@ -46,7 +48,17 @@
"annotations": "アノテーション",
"detailDisplay": "詳細表示",
"selectAll": "すべて選択",
- "selectNone": "選択解除"
+ "selectNone": "選択解除",
+ "comparison": "比較",
+ "openComparisonFile": "比較ファイルを開く",
+ "comparingFiles": "ファイルを比較中",
+ "endComparison": "比較を終了",
+ "changeComparisonSettings": "設定変更",
+ "truePositiveGt": "TP-GroundTruth",
+ "truePositivePred": "TP-Prediction",
+ "falsePositive": "FP",
+ "falseNegative": "FN",
+ "comparisonFilterInfo": "比較モード中はカテゴリフィルターが無効です"
},
"search": {
"clear": "検索をクリア",
@@ -67,6 +79,8 @@
"visible": "表示中",
"selected": "選択中",
"categoryDistribution": "カテゴリ分布",
+ "comparisonResults": "比較結果分布",
+ "comparisonMetrics": "比較メトリクス",
"sizeAnalysis": "サイズ分析",
"min": "最小",
"max": "最大",
@@ -112,7 +126,17 @@
"otherFields": "その他のフィールド",
"extendedFields": "拡張フィールド",
"multipleSelected": "{{count}}個のアノテーションが選択されています",
- "annotation": "アノテーション"
+ "annotation": "アノテーション",
+ "comparisonResult": "比較結果",
+ "type": "タイプ",
+ "role": "役割",
+ "groundTruth": "正解値",
+ "prediction": "推論結果",
+ "matches": "マッチング",
+ "noMatchingPartner": "マッチング相手なし",
+ "groundTruthAnnotation": "正解値アノテーション",
+ "predictionAnnotation": "推論結果アノテーション",
+ "belowThreshold": "閾値未満"
},
"empty": {
"noImageLoaded": "画像が読み込まれていません",
@@ -123,6 +147,8 @@
"errors": {
"loadImageFailed": "画像の読み込みに失敗しました",
"loadAnnotationsFailed": "アノテーションの読み込みに失敗しました",
+ "loadAnnotationFailed": "アノテーションの読み込みに失敗しました",
+ "imageRequiredFirst": "アノテーションを読み込む前に、画像またはフォルダを選択してください",
"invalidImageFormat": "有効な画像形式であることを確認してください",
"invalidJsonFormat": "有効なJSON形式であることを確認してください",
"exportFailed": "エクスポートに失敗しました",
@@ -154,6 +180,20 @@
"includeLicenses": "アノテーションにライセンス情報を含める",
"includeOption": "各アノテーションにカスタムオプションフィールドを追加",
"includeMultiPolygon": "マルチポリゴンアノテーションを含める(ランダムな副ポリゴン)",
+ "generating": "サンプルデータを生成中...",
+ "generatingSubMessage": "{{images}}枚の画像と{{annotations}}個のアノテーションを生成しています",
+ "includePairJson": "ペアJSONを生成(比較テスト用)",
+ "changePairCategoryNames": "ペアファイルのカテゴリ名を変更",
+ "changePairCategoryNamesDescription": "オンの場合、ペアファイルのカテゴリ名に「2」を付加します(例:rectangle → rectangle2)",
+ "maxPairMatches": "アノテーションごとの最大ペア数",
+ "maxPairMatchesDescription": "各アノテーションのマッチングペア数(1-5)",
+ "distributionSettings": "分布設定",
+ "distributionDescription": "ペアJSONのアノテーションタイプの分布を調整します。値は自動的に合計100%になります。",
+ "perfectMatch": "完全一致",
+ "partialMatch": "部分重複",
+ "noMatch": "マッチなし (FN)",
+ "additional": "追加 (FP)",
+ "total": "合計",
"generate": "生成",
"cancel": "キャンセル",
"generating": "生成中...",
@@ -164,6 +204,7 @@
"objectsWithAspectRatio": "1:1のアスペクト比のオブジェクト、最大サイズ: {{size}}px",
"availableShapes": "利用可能な形状: 四角形、三角形、星、五角形、六角形、ダイヤモンド、十字、矢印、家、八角形",
"outputFiles": "出力ファイル: {{name}}-image.png と {{name}}-annotation.json",
+ "pairJsonOutput": "ペアJSON出力: {{name}}-pair.json(ランダムな変更を含む)",
"generationFailed": "サンプルデータの生成に失敗しました"
},
"statistics": {
@@ -183,7 +224,17 @@
"average": "平均",
"close": "閉じる",
"copy": "コピー",
- "annotationStatistics": "アノテーション統計"
+ "annotationStatistics": "アノテーション統計",
+ "currentImage": "現在の画像",
+ "allImages": "全画像",
+ "comparisonMetrics": "比較評価指標",
+ "truePositives": "真陽性 (TP)",
+ "falsePositives": "偽陽性 (FP)",
+ "falseNegatives": "偽陰性 (FN)",
+ "precision": "Precision",
+ "recall": "Recall",
+ "f1Score": "F1-score",
+ "comparisonModeOnly": "比較評価指標は比較モード時のみ利用可能です"
},
"settings": {
"title": "設定",
@@ -223,7 +274,13 @@
"resetToDefault": "デフォルトに戻す",
"confirmReset": "すべてのカテゴリカラーをデフォルトに戻しますか?",
"transparent": "透明",
- "opaque": "不透明"
+ "opaque": "不透明",
+ "comparisonColors": "比較カラー設定",
+ "groundTruth": "正解データ",
+ "prediction": "予測結果",
+ "truePositive": "真陽性",
+ "falsePositive": "偽陽性",
+ "falseNegative": "偽陰性"
},
"imageSelection": {
"title": "画像を選択",
@@ -234,5 +291,147 @@
},
"files": {
"recentFiles": "ファイル"
+ },
+ "comparison": {
+ "title": "比較設定",
+ "fileSelection": "比較ファイル",
+ "selectFile": "ファイルを選択...",
+ "usingCurrentFile": "現在の比較ファイルを使用中",
+ "fileInfo": "{{images}}枚の画像、{{annotations}}個のアノテーション",
+ "roleSelection": "ファイルの役割",
+ "currentAsGT": "現在のファイル = 正解データ、比較ファイル = 予測結果",
+ "currentAsPred": "現在のファイル = 予測結果、比較ファイル = 正解データ",
+ "categoryMapping": "カテゴリマッピング",
+ "categoryMappingDescription": "カテゴリごとに比較対象を設定できます。割り当てなしのカテゴリは評価対象外となります。",
+ "currentFile": "現在のファイル",
+ "comparisonFile": "比較ファイル",
+ "noMapping": "マッピングなし",
+ "addMapping": "マッピングを追加",
+ "removeMapping": "マッピングを削除",
+ "add": "追加",
+ "selectCategory": "カテゴリを選択",
+ "allCategoriesMapped": "すべてのカテゴリが割り当て済み",
+ "noAvailableCategories": "利用可能なカテゴリがありません",
+ "unassigned": "未割当",
+ "unassignedComparison": "未割当(比較ファイル)",
+ "availableCategories": "利用可能なカテゴリ(比較ファイル)",
+ "clickToAssign": "クリックして割り当て",
+ "matchingSettings": "マッチング設定",
+ "iouThreshold": "IoU閾値",
+ "maxMatchesPerAnnotation": "アノテーションごとの最大マッチ数",
+ "startComparison": "比較開始",
+ "fileLoadError": "比較ファイルの読み込みに失敗しました",
+ "selectedImage": "選択された画像",
+ "imageSelection": "画像選択",
+ "selectImage": "画像を選択してください",
+ "imageIdMappingNote": "※ 選択した画像のアノテーションが現在の画像と比較されます",
+ "selectedImageAnnotations": "選択された画像のアノテーション数",
+ "currentFileInfo": "現在のファイル",
+ "currentImage": "現在の画像",
+ "colorSettings": "カラー設定",
+ "groundTruth": "正解データ",
+ "prediction": "予測結果",
+ "truePositive": "真陽性",
+ "falsePositive": "偽陽性",
+ "falseNegative": "偽陰性",
+ "displaySettings": "表示設定",
+ "showBoundingBoxes": "バウンディングボックスを表示",
+ "showLabels": "ラベルを表示",
+ "iouMethod": "IoU計算方法",
+ "bboxIoU": "バウンディングボックス",
+ "polygonIoU": "ポリゴン(セグメンテーション)",
+ "iouMethodDescription": "アノテーション間のIoU計算方法を選択",
+ "polygonIoUWarning": "ポリゴンIoUはグリッドベースの近似アルゴリズムを使用するため、正確な面積計算ではありません。精度と性能のバランスを考慮した近似値となります。",
+ "processing": "比較処理中...",
+ "processingSubMessage": "アノテーションを分析してマッチングを計算しています",
+ "imageNotFound": "画像が見つかりません",
+ "noCorrespondingImage": "比較ファイルに対応する画像ID {{imageId}} が見つかりません"
+ },
+ "histogram": {
+ "title": "分布ヒストグラム",
+ "noData": "アノテーションデータがありません",
+ "export": "CSV出力",
+ "copy": "クリップボードにコピー",
+ "settings": "設定",
+ "distributionType": "分布タイプ",
+ "width": "幅",
+ "height": "高さ",
+ "area": "面積(bbox)",
+ "polygonArea": "面積(ポリゴン)",
+ "aspectRatio": "アスペクト比",
+ "binCount": "ビン数",
+ "scale": "スケール",
+ "linear": "線形",
+ "log": "対数",
+ "categoryFilter": "カテゴリフィルタ",
+ "allCategories": "全カテゴリ",
+ "count": "数",
+ "percentage": "割合",
+ "statistics": "統計情報",
+ "mean": "平均",
+ "median": "中央値",
+ "std": "標準偏差",
+ "min": "最小",
+ "max": "最大",
+ "total": "合計",
+ "viewMode": "表示対象",
+ "currentImage": "現在の画像",
+ "allImages": "全画像",
+ "q1": "第1四分位数",
+ "q3": "第3四分位数",
+ "iqr": "四分位範囲",
+ "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": "全画像"
+ },
+ "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 ed61186..96ad099 100644
--- a/src/stores/index.ts
+++ b/src/stores/index.ts
@@ -3,5 +3,22 @@ export { useImageStore } from './useImageStore';
export { useToastStore, toast } from './useToastStore';
export { useRecentFilesStore } from './useRecentFilesStore';
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 {
+ HistogramType,
+ ScaleType,
+ HistogramData,
+ HistogramBin,
+ HistogramStatistics,
+} from './useHistogramStore';
+export type {
+ HeatmapType,
+ HeatmapSettings,
+ HeatmapBin as HeatmapBinType,
+ HeatmapData,
+} from './useHeatmapStore';
diff --git a/src/stores/useAnnotationStore.ts b/src/stores/useAnnotationStore.ts
index e304e5d..e805152 100644
--- a/src/stores/useAnnotationStore.ts
+++ b/src/stores/useAnnotationStore.ts
@@ -1,11 +1,12 @@
import { create } from 'zustand';
import { COCOData, COCOAnnotation, COCOCategory } from '../types/coco';
+import { DiffResult, DiffStatistics, DiffFilter, ComparisonSettings } from '../types/diff';
interface AnnotationState {
cocoData: COCOData | null;
- selectedAnnotationIds: number[];
+ selectedAnnotationIds: (number | string)[];
visibleCategoryIds: number[];
- hoveredAnnotationId: number | null;
+ hoveredAnnotationId: number | string | null;
currentImageId: number | null;
// Detail panel state
@@ -18,15 +19,24 @@ interface AnnotationState {
currentSearchIndex: number;
shouldCenterOnSelection: boolean;
+ // Comparison state
+ comparisonData: COCOData | null;
+ originalComparisonData: COCOData | null; // Store original unfiltered data
+ isComparing: boolean;
+ comparisonSettings: ComparisonSettings | null;
+ diffResults: Map
;
+ diffStatistics: DiffStatistics | null;
+ diffFilters: Set;
+
// Actions
setCocoData: (data: COCOData) => void;
clearCocoData: () => void;
- selectAnnotation: (id: number, multiSelect?: boolean) => void;
+ selectAnnotation: (id: number | string, multiSelect?: boolean) => void;
clearSelection: () => void;
toggleCategoryVisibility: (categoryId: number) => void;
showAllCategories: () => void;
hideAllCategories: () => void;
- setHoveredAnnotation: (id: number | null) => void;
+ setHoveredAnnotation: (id: number | string | null) => void;
// Detail panel actions
setDetailPanelOpen: (open: boolean) => void;
@@ -48,6 +58,19 @@ interface AnnotationState {
getSelectedAnnotations: () => COCOAnnotation[];
getCategoryById: (id: number) => COCOCategory | undefined;
getAnnotationsForCurrentImage: () => COCOAnnotation[];
+
+ // Comparison actions
+ 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) => ({
@@ -62,6 +85,13 @@ export const useAnnotationStore = create((set, get) => ({
searchResults: [],
currentSearchIndex: -1,
shouldCenterOnSelection: false,
+ comparisonData: null,
+ originalComparisonData: null,
+ isComparing: false,
+ comparisonSettings: null,
+ diffResults: new Map(),
+ diffStatistics: null,
+ diffFilters: new Set(),
setCocoData: (data) => {
set({
@@ -253,6 +283,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: () => {
@@ -261,4 +293,211 @@ export const useAnnotationStore = create((set, get) => ({
return state.cocoData.annotations.filter((ann) => ann.image_id === state.currentImageId);
},
+
+ // Comparison actions
+ setComparisonData: (originalData, filteredData, settings) => {
+ set({
+ originalComparisonData: originalData,
+ comparisonData: filteredData,
+ comparisonSettings: settings,
+ isComparing: true,
+ diffFilters: new Set(['tp-gt', 'tp-pred', 'fp', 'fn']), // Show all result types by default
+ });
+ get().calculateDiff();
+
+ console.log('🎯 COMPARISON STARTED - Auto-enabled all result filters:', {
+ diffFiltersSize: 4,
+ enabledFilters: ['tp-gt', 'tp-pred', 'fp', 'fn'],
+ });
+ },
+
+ clearComparison: () => {
+ console.log('🧹 CLEARING COMPARISON - Before:', {
+ isComparing: get().isComparing,
+ hasComparisonData: !!get().comparisonData,
+ diffResultsSize: get().diffResults.size,
+ diffFiltersSize: get().diffFilters.size,
+ });
+
+ // Get current state before clearing
+ const currentState = get();
+
+ set({
+ comparisonData: null,
+ originalComparisonData: null,
+ comparisonSettings: null,
+ isComparing: false,
+ diffResults: new Map(),
+ diffStatistics: null,
+ diffFilters: new Set(),
+ // Auto-enable all categories when exiting comparison mode
+ visibleCategoryIds:
+ currentState.cocoData?.categories.map((cat) => cat.id) || currentState.visibleCategoryIds,
+ });
+
+ console.log('🧹 CLEARING COMPARISON - After:', {
+ isComparing: get().isComparing,
+ hasComparisonData: !!get().comparisonData,
+ diffResultsSize: get().diffResults.size,
+ diffFiltersSize: get().diffFilters.size,
+ visibleCategoriesCount: get().visibleCategoryIds.length,
+ autoEnabledCategories: true,
+ });
+
+ // Force a re-calculation of visible annotations by triggering a state update
+ // This ensures all comparison-related rendering is cleared immediately
+ const state = get();
+ if (state.cocoData) {
+ // Trigger a minor state update to force re-render
+ set({ selectedAnnotationIds: [...state.selectedAnnotationIds] });
+ console.log('🧹 Forced re-render by updating selectedAnnotationIds');
+ }
+ },
+
+ calculateDiff: () => {
+ const state = get();
+ if (!state.cocoData || !state.comparisonData || !state.comparisonSettings) return;
+
+ // Import diff calculation logic dynamically to avoid circular dependencies
+ import('../utils/diffCalculator').then(({ calculateDiff }) => {
+ // Use role-agnostic approach: always pass primary data as first parameter
+ const { results, statistics } = calculateDiff(
+ state.cocoData!, // dataA (primary)
+ state.comparisonData!, // dataB (comparison)
+ state.comparisonSettings!
+ );
+ set({
+ diffResults: results,
+ diffStatistics: statistics,
+ });
+ });
+ },
+
+ toggleDiffFilter: (filter) => {
+ set((state) => {
+ const newFilters = new Set(state.diffFilters);
+
+ if (newFilters.has(filter)) {
+ newFilters.delete(filter);
+ } else {
+ newFilters.add(filter);
+ }
+
+ return { diffFilters: newFilters };
+ });
+ },
+
+ setDiffFilters: (filters) => {
+ set({ diffFilters: filters });
+ },
+
+ updateComparisonSettings: (settings) => {
+ set({ comparisonSettings: settings });
+ get().calculateDiff();
+ },
+
+ 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
+ );
+
+ // 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: 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();
+ };
+ })(),
}));
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/stores/useHistogramStore.ts b/src/stores/useHistogramStore.ts
new file mode 100644
index 0000000..1712e6a
--- /dev/null
+++ b/src/stores/useHistogramStore.ts
@@ -0,0 +1,172 @@
+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;
+ skewness: number;
+ kurtosis: number;
+}
+
+export interface HistogramSettings {
+ binCount: number;
+ scale: ScaleType;
+ selectedCategories: Set;
+ sizeRange: [number, number] | null;
+ viewMode: 'current' | 'all';
+}
+
+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;
+ setViewMode: (mode: 'current' | 'all') => void;
+ resetSettings: () => void;
+}
+
+const defaultSettings: HistogramSettings = {
+ binCount: 20,
+ scale: 'linear',
+ selectedCategories: new Set(),
+ sizeRange: null,
+ viewMode: 'all',
+};
+
+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,
+ })),
+
+ setViewMode: (mode) =>
+ set((state) => ({
+ settings: {
+ ...state.settings,
+ viewMode: mode,
+ },
+ // ビューモード変更時はデータをクリア(再計算が必要)
+ histogramData: null,
+ })),
+
+ resetSettings: () =>
+ set(() => ({
+ settings: defaultSettings,
+ histogramType: 'area',
+ histogramData: null,
+ highlightedAnnotations: new Set(),
+ })),
+ }))
+);
diff --git a/src/stores/useImageStore.ts b/src/stores/useImageStore.ts
index c0b96ae..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,14 +92,64 @@ export const useImageStore = create((set, get) => ({
set({ error, isLoading: false });
},
+ setPreserveViewState: (preserve) => {
+ set({ preserveViewState: preserve });
+ },
+
zoomIn: () => {
- const currentZoom = get().zoom;
- set({ zoom: Math.min(10, currentZoom * 1.2) });
+ const state = get();
+ const currentZoom = state.zoom;
+ const newZoom = Math.min(10, currentZoom * 1.2);
+
+ // Calculate zoom around viewport center
+ if (state.containerSize) {
+ const viewportCenter = {
+ x: state.containerSize.width / 2,
+ y: state.containerSize.height / 2,
+ };
+
+ const pointBeforeZoom = {
+ x: (viewportCenter.x - state.pan.x) / currentZoom,
+ y: (viewportCenter.y - state.pan.y) / currentZoom,
+ };
+
+ const newPan = {
+ x: viewportCenter.x - pointBeforeZoom.x * newZoom,
+ y: viewportCenter.y - pointBeforeZoom.y * newZoom,
+ };
+
+ set({ zoom: newZoom, pan: newPan });
+ } else {
+ set({ zoom: newZoom });
+ }
},
zoomOut: () => {
- const currentZoom = get().zoom;
- set({ zoom: Math.max(0.1, currentZoom / 1.2) });
+ const state = get();
+ const currentZoom = state.zoom;
+ const newZoom = Math.max(0.1, currentZoom / 1.2);
+
+ // Calculate zoom around viewport center
+ if (state.containerSize) {
+ const viewportCenter = {
+ x: state.containerSize.width / 2,
+ y: state.containerSize.height / 2,
+ };
+
+ const pointBeforeZoom = {
+ x: (viewportCenter.x - state.pan.x) / currentZoom,
+ y: (viewportCenter.y - state.pan.y) / currentZoom,
+ };
+
+ const newPan = {
+ x: viewportCenter.x - pointBeforeZoom.x * newZoom,
+ y: viewportCenter.y - pointBeforeZoom.y * newZoom,
+ };
+
+ set({ zoom: newZoom, pan: newPan });
+ } else {
+ set({ zoom: newZoom });
+ }
},
fitToWindow: () => {
diff --git a/src/stores/useLoadingStore.ts b/src/stores/useLoadingStore.ts
new file mode 100644
index 0000000..c93b4ec
--- /dev/null
+++ b/src/stores/useLoadingStore.ts
@@ -0,0 +1,19 @@
+import { create } from 'zustand';
+
+interface LoadingState {
+ isLoading: boolean;
+ message: string;
+ subMessage?: string;
+ progress?: number;
+ setLoading: (loading: boolean, message?: string, subMessage?: string, progress?: number) => void;
+}
+
+export const useLoadingStore = create((set) => ({
+ isLoading: false,
+ message: '',
+ subMessage: undefined,
+ progress: undefined,
+
+ setLoading: (loading, message = '', subMessage = undefined, progress = undefined) =>
+ set({ isLoading: loading, message, subMessage, progress }),
+}));
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 415d152..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 {
@@ -33,6 +33,17 @@ interface ColorSettings {
selectedFillOpacity: number;
hoverFillOpacity: number;
strokeOpacity: number;
+ // Comparison colors
+ comparison: {
+ gtColors: {
+ tp: string; // True Positive (Ground Truth)
+ fn: string; // False Negative
+ };
+ predColors: {
+ tp: string; // True Positive (Prediction)
+ fp: string; // False Positive
+ };
+ };
}
interface SettingsState {
@@ -102,7 +113,7 @@ const defaultSettings: Omit<
promotedFields: [],
},
panelLayout: {
- leftPanelTabs: ['control', 'files'],
+ leftPanelTabs: ['control', 'files', 'navigation'],
rightPanelTabs: ['info', 'detail'],
defaultLeftTab: 'control',
defaultRightTab: 'info',
@@ -113,6 +124,16 @@ const defaultSettings: Omit<
selectedFillOpacity: 0.5,
hoverFillOpacity: 0.4,
strokeOpacity: 1.0,
+ comparison: {
+ gtColors: {
+ tp: '#4caf50', // Green
+ fn: '#ff9800', // Orange
+ },
+ predColors: {
+ tp: '#66bb6a', // Light Green
+ fp: '#f44336', // Red
+ },
+ },
},
isSettingsModalOpen: false,
};
@@ -224,6 +245,29 @@ export const useSettingsStore = create()(
panelLayout: state.panelLayout,
colors: state.colors,
}),
+ migrate: (persistedState: unknown) => {
+ // Migrate from older versions that don't have comparison colors
+ if (
+ persistedState &&
+ typeof persistedState === 'object' &&
+ 'colors' in persistedState &&
+ persistedState.colors &&
+ typeof persistedState.colors === 'object' &&
+ !('comparison' in persistedState.colors)
+ ) {
+ (persistedState.colors as Partial).comparison = {
+ gtColors: {
+ tp: '#4caf50', // Green
+ fn: '#ff9800', // Orange
+ },
+ predColors: {
+ tp: '#66bb6a', // Light Green
+ fp: '#f44336', // Red
+ },
+ };
+ }
+ return persistedState;
+ },
}
)
);
diff --git a/src/styles/themes.css b/src/styles/themes.css
index a2799eb..96d9463 100644
--- a/src/styles/themes.css
+++ b/src/styles/themes.css
@@ -34,6 +34,11 @@
--color-error: #ef4444;
--color-info: #8b5cf6;
+ /* Warning styles */
+ --color-warning-bg: #fef3cd;
+ --color-warning-text: #856404;
+ --color-warning-border: #ffeaa7;
+
/* Component specific */
--color-overlay: rgba(0, 0, 0, 0.5);
--color-shadow: rgba(0, 0, 0, 0.1);
@@ -80,6 +85,11 @@
--color-error: #f87171;
--color-info: #a78bfa;
+ /* Warning styles */
+ --color-warning-bg: #44403c;
+ --color-warning-text: #fbbf24;
+ --color-warning-border: #57534e;
+
/* Component specific */
--color-overlay: rgba(0, 0, 0, 0.7);
--color-shadow: rgba(0, 0, 0, 0.3);
diff --git a/src/types/diff.ts b/src/types/diff.ts
new file mode 100644
index 0000000..b276a5e
--- /dev/null
+++ b/src/types/diff.ts
@@ -0,0 +1,61 @@
+import type { COCOAnnotation } from './coco';
+
+export interface MatchedAnnotation {
+ gtAnnotation: COCOAnnotation;
+ predAnnotation: COCOAnnotation;
+ iou: number;
+}
+
+export interface DiffResult {
+ imageId: number;
+ truePositives: MatchedAnnotation[];
+ falsePositives: COCOAnnotation[];
+ falseNegatives: COCOAnnotation[];
+ // Matches below threshold but with IoU > 0
+ belowThresholdMatches: MatchedAnnotation[];
+}
+
+export interface CategoryStats {
+ tp: number;
+ fp: number;
+ fn: number;
+ precision: number;
+ recall: number;
+ f1: number;
+}
+
+export interface DiffStatistics {
+ total: CategoryStats;
+ byCategory: Map;
+}
+
+export type DiffFilter = 'tp-gt' | 'tp-pred' | 'fp' | 'fn';
+
+export interface DiffColorSettings {
+ gtColors: {
+ tp: string;
+ fn: string;
+ };
+ predColors: {
+ tp: string;
+ fp: string;
+ };
+}
+
+export interface DiffDisplaySettings {
+ showBoundingBoxes: boolean;
+ showLabels: boolean;
+}
+
+export type IoUMethod = 'bbox' | 'polygon';
+
+export interface ComparisonSettings {
+ gtFileId: string;
+ predFileId: string;
+ iouThreshold: number;
+ categoryMapping: Map; // GT category ID -> Pred category IDs (1対多)
+ colorSettings: DiffColorSettings;
+ displaySettings: DiffDisplaySettings;
+ maxMatchesPerAnnotation?: number; // Maximum number of matches allowed per annotation (default: 1)
+ iouMethod?: IoUMethod; // Method to calculate IoU (default: 'bbox')
+}
diff --git a/src/utils/diffCalculator.ts b/src/utils/diffCalculator.ts
new file mode 100644
index 0000000..5db39f7
--- /dev/null
+++ b/src/utils/diffCalculator.ts
@@ -0,0 +1,614 @@
+import type { COCOData, COCOAnnotation } from '../types/coco';
+import type {
+ DiffResult,
+ MatchedAnnotation,
+ DiffStatistics,
+ CategoryStats,
+ ComparisonSettings,
+ IoUMethod,
+} from '../types/diff';
+
+/**
+ * Calculate IoU (Intersection over Union) between two bounding boxes
+ */
+function calculateBBoxIoU(box1: number[], box2: number[]): number {
+ const [x1, y1, w1, h1] = box1;
+ const [x2, y2, w2, h2] = box2;
+
+ // Calculate intersection area
+ const xLeft = Math.max(x1, x2);
+ const yTop = Math.max(y1, y2);
+ const xRight = Math.min(x1 + w1, x2 + w2);
+ const yBottom = Math.min(y1 + h1, y2 + h2);
+
+ if (xRight < xLeft || yBottom < yTop) {
+ return 0; // No intersection
+ }
+
+ const intersectionArea = (xRight - xLeft) * (yBottom - yTop);
+ const box1Area = w1 * h1;
+ const box2Area = w2 * h2;
+ const unionArea = box1Area + box2Area - intersectionArea;
+
+ return intersectionArea / unionArea;
+}
+
+/**
+ * Check if a point is inside a polygon using ray casting algorithm
+ */
+function isPointInPolygon(x: number, y: number, polygon: number[]): boolean {
+ let inside = false;
+ const n = polygon.length / 2;
+
+ for (let i = 0, j = n - 1; i < n; j = i++) {
+ const xi = polygon[i * 2];
+ const yi = polygon[i * 2 + 1];
+ const xj = polygon[j * 2];
+ const yj = polygon[j * 2 + 1];
+
+ if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) {
+ inside = !inside;
+ }
+ }
+
+ return inside;
+}
+
+/**
+ * Calculate IoU between two polygons
+ * Uses a grid-based approximation for efficiency
+ */
+function calculatePolygonIoU(
+ seg1: number[][],
+ seg2: number[][],
+ bbox1: number[],
+ bbox2: number[]
+): number {
+ // If segmentations are empty, fall back to bbox IoU
+ if (!seg1 || seg1.length === 0 || !seg2 || seg2.length === 0) {
+ return calculateBBoxIoU(bbox1, bbox2);
+ }
+
+ // Get the first polygon from each segmentation (COCO can have multiple polygons)
+ const poly1 = seg1[0];
+ const poly2 = seg2[0];
+
+ if (poly1.length < 6 || poly2.length < 6) {
+ // Need at least 3 points (6 coordinates) to form a polygon
+ return calculateBBoxIoU(bbox1, bbox2);
+ }
+
+ // Find bounding box of the union
+ const [x1, y1, w1, h1] = bbox1;
+ const [x2, y2, w2, h2] = bbox2;
+ const minX = Math.min(x1, x2);
+ const minY = Math.min(y1, y2);
+ const maxX = Math.max(x1 + w1, x2 + w2);
+ const maxY = Math.max(y1 + h1, y2 + h2);
+
+ // Use a grid resolution based on the size of the region
+ const gridSize = Math.max(1, Math.min((maxX - minX) / 50, (maxY - minY) / 50));
+
+ let intersectionCount = 0;
+ let union1Count = 0;
+ let union2Count = 0;
+
+ // Sample points in a grid
+ for (let x = minX; x <= maxX; x += gridSize) {
+ for (let y = minY; y <= maxY; y += gridSize) {
+ const inPoly1 = isPointInPolygon(x, y, poly1);
+ const inPoly2 = isPointInPolygon(x, y, poly2);
+
+ if (inPoly1 && inPoly2) {
+ intersectionCount++;
+ }
+ if (inPoly1) {
+ union1Count++;
+ }
+ if (inPoly2) {
+ union2Count++;
+ }
+ }
+ }
+
+ const unionCount = union1Count + union2Count - intersectionCount;
+
+ if (unionCount === 0) {
+ return 0;
+ }
+
+ return intersectionCount / unionCount;
+}
+
+/**
+ * Calculate IoU based on the specified method
+ */
+function calculateIoU(
+ ann1: COCOAnnotation,
+ ann2: COCOAnnotation,
+ method: IoUMethod = 'bbox'
+): number {
+ if (method === 'polygon') {
+ return calculatePolygonIoU(ann1.segmentation, ann2.segmentation, ann1.bbox, ann2.bbox);
+ }
+ return calculateBBoxIoU(ann1.bbox, ann2.bbox);
+}
+
+/**
+ * Find overlapping annotations between two datasets with multiple matching support
+ */
+function findOverlappingAnnotationsMultiple(
+ datasetA: COCOAnnotation[],
+ datasetB: COCOAnnotation[],
+ iouThreshold: number,
+ categoryMapping: Map,
+ maxMatchesPerAnnotation: number = 1,
+ iouMethod: IoUMethod = 'bbox'
+): {
+ matches: { annotationA: COCOAnnotation; annotationB: COCOAnnotation; iou: number }[];
+ unmatchedA: COCOAnnotation[];
+ unmatchedB: COCOAnnotation[];
+ belowThresholdMatches: {
+ annotationA: COCOAnnotation;
+ annotationB: COCOAnnotation;
+ iou: number;
+ }[];
+} {
+ const matches: { annotationA: COCOAnnotation; annotationB: COCOAnnotation; iou: number }[] = [];
+ const belowThresholdMatches: {
+ annotationA: COCOAnnotation;
+ annotationB: COCOAnnotation;
+ iou: number;
+ }[] = [];
+ const matchCountA = new Map();
+ const matchCountB = new Map();
+
+ // Find all potential matches (both above and below threshold)
+ const potentialMatches: {
+ annotationA: COCOAnnotation;
+ annotationB: COCOAnnotation;
+ iou: number;
+ }[] = [];
+ const potentialBelowThreshold: {
+ annotationA: COCOAnnotation;
+ annotationB: COCOAnnotation;
+ iou: number;
+ }[] = [];
+
+ let totalComparisons = 0;
+ let aboveThresholdCount = 0;
+ let belowThresholdCount = 0;
+ let zeroIouCount = 0;
+
+ datasetA.forEach((annA) => {
+ datasetB.forEach((annB) => {
+ totalComparisons++;
+
+ // Check category mapping (1対多対応)
+ const mappedCategories = categoryMapping.get(annA.category_id);
+ if (mappedCategories !== undefined) {
+ // マッピングが設定されている場合、マッピング先のカテゴリに含まれるかチェック
+ if (!mappedCategories.includes(annB.category_id)) return;
+ } else {
+ // マッピングが設定されていない場合は評価対象外(スキップ)
+ return;
+ }
+
+ // Calculate IoU
+ const iou = calculateIoU(annA, annB, iouMethod);
+
+ // Log first few IoU calculations for debugging
+ if (totalComparisons <= 5) {
+ console.debug('IoU calculation:', {
+ annA_id: annA.id,
+ annB_id: annB.id,
+ bboxA: annA.bbox,
+ bboxB: annB.bbox,
+ iou,
+ threshold: iouThreshold,
+ method: iouMethod,
+ });
+ }
+
+ if (iou >= iouThreshold) {
+ aboveThresholdCount++;
+ potentialMatches.push({
+ annotationA: annA,
+ annotationB: annB,
+ iou,
+ });
+ } else if (iou > 0) {
+ // Store below threshold matches that have some overlap
+ belowThresholdCount++;
+ potentialBelowThreshold.push({
+ annotationA: annA,
+ annotationB: annB,
+ iou,
+ });
+ } else {
+ zeroIouCount++;
+ }
+ });
+ });
+
+ console.debug('IoU comparison summary:', {
+ totalComparisons,
+ aboveThresholdCount,
+ belowThresholdCount,
+ zeroIouCount,
+ threshold: iouThreshold,
+ datasetASize: datasetA.length,
+ datasetBSize: datasetB.length,
+ });
+
+ // Sort by IoU descending
+ potentialMatches.sort((a, b) => b.iou - a.iou);
+
+ // Select matches respecting maxMatchesPerAnnotation
+ potentialMatches.forEach((match) => {
+ const countA = matchCountA.get(match.annotationA.id) || 0;
+ const countB = matchCountB.get(match.annotationB.id) || 0;
+
+ if (countA < maxMatchesPerAnnotation && countB < maxMatchesPerAnnotation) {
+ matches.push(match);
+ matchCountA.set(match.annotationA.id, countA + 1);
+ matchCountB.set(match.annotationB.id, countB + 1);
+ }
+ });
+
+ // Sort and process below threshold matches for unmatched annotations only
+ potentialBelowThreshold.sort((a, b) => b.iou - a.iou);
+ console.debug(`Processing ${potentialBelowThreshold.length} potential below threshold matches`);
+
+ potentialBelowThreshold.forEach((match) => {
+ // Only include if both annotations are unmatched (have no above-threshold matches)
+ const hasMatchA = matchCountA.has(match.annotationA.id);
+ const hasMatchB = matchCountB.has(match.annotationB.id);
+
+ console.debug('Below threshold candidate:', {
+ aId: match.annotationA.id,
+ bId: match.annotationB.id,
+ iou: match.iou,
+ hasMatchA,
+ hasMatchB,
+ willInclude: !hasMatchA || !hasMatchB,
+ });
+
+ if (!hasMatchA || !hasMatchB) {
+ belowThresholdMatches.push(match);
+ }
+ });
+
+ // Find unmatched annotations
+ const unmatchedA = datasetA.filter((ann) => !matchCountA.has(ann.id));
+ const unmatchedB = datasetB.filter((ann) => !matchCountB.has(ann.id));
+
+ return { matches, unmatchedA, unmatchedB, belowThresholdMatches };
+}
+
+/**
+ * Find overlapping annotations between two datasets (role-agnostic) - Legacy single match version
+ */
+function findOverlappingAnnotations(
+ datasetA: COCOAnnotation[],
+ datasetB: COCOAnnotation[],
+ iouThreshold: number,
+ categoryMapping: Map,
+ iouMethod: IoUMethod = 'bbox'
+): {
+ matches: { annotationA: COCOAnnotation; annotationB: COCOAnnotation; iou: number }[];
+ unmatchedA: COCOAnnotation[];
+ unmatchedB: COCOAnnotation[];
+ belowThresholdMatches: {
+ annotationA: COCOAnnotation;
+ annotationB: COCOAnnotation;
+ iou: number;
+ }[];
+} {
+ const matches: { annotationA: COCOAnnotation; annotationB: COCOAnnotation; iou: number }[] = [];
+ const belowThresholdMatches: {
+ annotationA: COCOAnnotation;
+ annotationB: COCOAnnotation;
+ iou: number;
+ }[] = [];
+ const usedA = new Set();
+ const usedB = new Set();
+
+ // For each annotation in dataset A, find the best matching annotation in dataset B
+ datasetA.forEach((annA) => {
+ let bestMatch: { annotation: COCOAnnotation; iou: number } | null = null;
+
+ datasetB.forEach((annB) => {
+ // Skip if already matched
+ if (usedB.has(annB.id)) return;
+
+ // Check category mapping (1対多対応)
+ const mappedCategories = categoryMapping.get(annA.category_id);
+ if (mappedCategories !== undefined) {
+ // マッピングが設定されている場合、マッピング先のカテゴリに含まれるかチェック
+ if (!mappedCategories.includes(annB.category_id)) return;
+ } else {
+ // マッピングが設定されていない場合は評価対象外(スキップ)
+ return;
+ }
+
+ // Calculate IoU
+ const iou = calculateIoU(annA, annB, iouMethod);
+ if (iou >= iouThreshold) {
+ if (!bestMatch || iou > bestMatch.iou) {
+ bestMatch = { annotation: annB, iou };
+ }
+ }
+ });
+
+ if (bestMatch !== null) {
+ const foundMatch = bestMatch as { annotation: COCOAnnotation; iou: number };
+ matches.push({
+ annotationA: annA,
+ annotationB: foundMatch.annotation,
+ iou: foundMatch.iou,
+ });
+ usedA.add(annA.id);
+ usedB.add(foundMatch.annotation.id);
+ }
+ });
+
+ // Find unmatched annotations
+ const unmatchedA = datasetA.filter((ann) => !usedA.has(ann.id));
+ const unmatchedB = datasetB.filter((ann) => !usedB.has(ann.id));
+
+ // Find below threshold matches among unmatched annotations
+ unmatchedA.forEach((annA) => {
+ unmatchedB.forEach((annB) => {
+ // Check category mapping (1対多対応)
+ const mappedCategories = categoryMapping.get(annA.category_id);
+ if (mappedCategories !== undefined) {
+ // マッピングが設定されている場合、マッピング先のカテゴリに含まれるかチェック
+ if (!mappedCategories.includes(annB.category_id)) return;
+ } else {
+ // マッピングが設定されていない場合は評価対象外(スキップ)
+ return;
+ }
+
+ // Calculate IoU
+ const iou = calculateIoU(annA, annB, iouMethod);
+ if (iou > 0 && iou < iouThreshold) {
+ belowThresholdMatches.push({
+ annotationA: annA,
+ annotationB: annB,
+ iou,
+ });
+ }
+ });
+ });
+
+ console.debug(`Single match: Found ${belowThresholdMatches.length} below threshold matches`);
+
+ return { matches, unmatchedA, unmatchedB, belowThresholdMatches };
+}
+
+/**
+ * Calculate statistics from diff results
+ */
+function calculateStatistics(
+ diffResults: Map,
+ categories: { id: number; name: string }[]
+): DiffStatistics {
+ const categoryStatsMap = new Map();
+ let totalTP = 0;
+ let totalFP = 0;
+ let totalFN = 0;
+
+ // Initialize category stats
+ categories.forEach((cat) => {
+ categoryStatsMap.set(cat.id, { tp: 0, fp: 0, fn: 0 });
+ });
+
+ // Aggregate results
+ diffResults.forEach((result) => {
+ // Count true positives
+ result.truePositives.forEach((match) => {
+ totalTP++;
+ const catId = match.gtAnnotation.category_id;
+ const stats = categoryStatsMap.get(catId);
+ if (stats) stats.tp++;
+ });
+
+ // Count false positives
+ result.falsePositives.forEach((ann) => {
+ totalFP++;
+ const catId = ann.category_id;
+ const stats = categoryStatsMap.get(catId);
+ if (stats) stats.fp++;
+ });
+
+ // Count false negatives
+ result.falseNegatives.forEach((ann) => {
+ totalFN++;
+ const catId = ann.category_id;
+ const stats = categoryStatsMap.get(catId);
+ if (stats) stats.fn++;
+ });
+ });
+
+ // Calculate metrics
+ const calculateMetrics = (tp: number, fp: number, fn: number): CategoryStats => {
+ const precision = tp + fp > 0 ? tp / (tp + fp) : 0;
+ const recall = tp + fn > 0 ? tp / (tp + fn) : 0;
+ const f1 = precision + recall > 0 ? (2 * (precision * recall)) / (precision + recall) : 0;
+
+ return { tp, fp, fn, precision, recall, f1 };
+ };
+
+ // Build final statistics
+ const byCategory = new Map();
+ categories.forEach((cat) => {
+ const stats = categoryStatsMap.get(cat.id);
+ if (stats) {
+ byCategory.set(cat.id, {
+ ...calculateMetrics(stats.tp, stats.fp, stats.fn),
+ categoryName: cat.name,
+ });
+ }
+ });
+
+ return {
+ total: calculateMetrics(totalTP, totalFP, totalFN),
+ byCategory,
+ };
+}
+
+/**
+ * Main diff calculation function - role-agnostic approach
+ */
+export function calculateDiff(
+ dataA: COCOData,
+ dataB: COCOData,
+ settings: ComparisonSettings
+): {
+ results: Map;
+ statistics: DiffStatistics;
+} {
+ // Debug logging to verify data sources
+ console.log('calculateDiff called with:', {
+ dataA_annotations: dataA.annotations.length,
+ dataB_annotations: dataB.annotations.length,
+ gtFileId: settings.gtFileId,
+ dataA_sample_ids: dataA.annotations.slice(0, 3).map((a) => a.id),
+ dataB_sample_ids: dataB.annotations.slice(0, 3).map((a) => a.id),
+ areDataSetsIdentical: dataA.annotations === dataB.annotations,
+ annotationIdsOverlap: dataA.annotations.some((a) =>
+ dataB.annotations.some((b) => b.id === a.id)
+ ),
+ maxMatchesPerAnnotation: settings.maxMatchesPerAnnotation || 1,
+ });
+
+ const results = new Map();
+
+ // Get all unique image IDs from both datasets
+ const allImageIds = new Set();
+ dataA.annotations.forEach((ann) => allImageIds.add(ann.image_id));
+ dataB.annotations.forEach((ann) => allImageIds.add(ann.image_id));
+
+ // Process each image
+ allImageIds.forEach((imageId) => {
+ const annotationsA = dataA.annotations.filter((ann) => ann.image_id === imageId);
+ const annotationsB = dataB.annotations.filter((ann) => ann.image_id === imageId);
+
+ // Step 1: Find overlapping annotations (role-agnostic)
+ // Use multiple matching if specified, otherwise use single matching
+ const iouMethod = settings.iouMethod || 'bbox';
+ const overlappingResult =
+ settings.maxMatchesPerAnnotation && settings.maxMatchesPerAnnotation > 1
+ ? findOverlappingAnnotationsMultiple(
+ annotationsA,
+ annotationsB,
+ settings.iouThreshold,
+ settings.categoryMapping,
+ settings.maxMatchesPerAnnotation,
+ iouMethod
+ )
+ : findOverlappingAnnotations(
+ annotationsA,
+ annotationsB,
+ settings.iouThreshold,
+ settings.categoryMapping,
+ iouMethod
+ );
+
+ const { matches, unmatchedA, unmatchedB, belowThresholdMatches } = overlappingResult;
+
+ // Step 2: Classify based on role settings
+ const truePositives: MatchedAnnotation[] = [];
+ const falsePositives: COCOAnnotation[] = [];
+ const falseNegatives: COCOAnnotation[] = [];
+
+ // Determine which dataset is GT and which is Pred based on settings
+ // Note: settings should contain information about which dataset is GT
+ // For now, we'll assume the first parameter (dataA) corresponds to the primary file
+ const isPrimaryGT = settings.gtFileId === 'primary';
+
+ if (isPrimaryGT) {
+ // dataA is GT, dataB is Pred
+ matches.forEach((match) => {
+ truePositives.push({
+ gtAnnotation: match.annotationA,
+ predAnnotation: match.annotationB,
+ iou: match.iou,
+ });
+ });
+ falseNegatives.push(...unmatchedA); // Unmatched GT
+ falsePositives.push(...unmatchedB); // Unmatched Pred
+ } else {
+ // dataA is Pred, dataB is GT
+ matches.forEach((match) => {
+ truePositives.push({
+ gtAnnotation: match.annotationB,
+ predAnnotation: match.annotationA,
+ iou: match.iou,
+ });
+ });
+ falseNegatives.push(...unmatchedB); // Unmatched GT
+ falsePositives.push(...unmatchedA); // Unmatched Pred
+ }
+
+ // Debug logging for first image
+ if (imageId === allImageIds.values().next().value) {
+ console.log('Diff results for first image:', {
+ imageId,
+ truePositives: truePositives.map((tp) => ({
+ gtId: tp.gtAnnotation.id,
+ predId: tp.predAnnotation.id,
+ iou: tp.iou,
+ })),
+ falsePositives: falsePositives.map((fp) => fp.id),
+ falseNegatives: falseNegatives.map((fn) => fn.id),
+ isPrimaryGT,
+ });
+ }
+
+ // Convert below threshold matches to MatchedAnnotation format
+ const belowThresholdMatchedAnnotations: MatchedAnnotation[] = [];
+
+ console.debug(
+ `Image ${imageId}: Found ${belowThresholdMatches.length} below threshold matches`
+ );
+
+ belowThresholdMatches.forEach((match) => {
+ console.debug('Below threshold match:', {
+ aId: match.annotationA.id,
+ bId: match.annotationB.id,
+ iou: match.iou,
+ isPrimaryGT,
+ });
+
+ if (isPrimaryGT) {
+ belowThresholdMatchedAnnotations.push({
+ gtAnnotation: match.annotationA,
+ predAnnotation: match.annotationB,
+ iou: match.iou,
+ });
+ } else {
+ belowThresholdMatchedAnnotations.push({
+ gtAnnotation: match.annotationB,
+ predAnnotation: match.annotationA,
+ iou: match.iou,
+ });
+ }
+ });
+
+ results.set(imageId, {
+ imageId,
+ truePositives,
+ falsePositives,
+ falseNegatives,
+ belowThresholdMatches: belowThresholdMatchedAnnotations,
+ });
+ });
+
+ // Calculate statistics using the appropriate GT dataset for categories
+ const gtDataForStats = settings.gtFileId === 'primary' ? dataA : dataB;
+ const statistics = calculateStatistics(results, gtDataForStats.categories);
+
+ return { results, statistics };
+}
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
new file mode 100644
index 0000000..500f417
--- /dev/null
+++ b/src/utils/histogram.ts
@@ -0,0 +1,325 @@
+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,
+ skewness: 0,
+ kurtosis: 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];
+
+ // 歪度 (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 };
+}
+
+// ビンの範囲を計算
+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,
+ currentImageId?: number | null
+): HistogramData | null {
+ if (!cocoData.annotations || cocoData.annotations.length === 0) {
+ return null;
+ }
+
+ // フィルタリング
+ 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) => 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 {
+ try {
+ 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 =
+ 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']);
+
+ // 安全な数値フォーマッティング
+ 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}`);
+ }
+}
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 0fba2f4..6f3300d 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -1,2 +1,4 @@
export * from './statistics';
export * from './fieldExtractor';
+export * from './histogram';
+export * from './heatmap';
diff --git a/src/utils/statistics.ts b/src/utils/statistics.ts
index c4bfbe5..58df3c2 100644
--- a/src/utils/statistics.ts
+++ b/src/utils/statistics.ts
@@ -35,7 +35,7 @@ export function calculateAnnotationStatistics(
cocoData: COCOData | null,
imageId: number | null,
visibleCategoryIds: number[],
- selectedAnnotationIds: number[]
+ selectedAnnotationIds: (number | string)[]
): AnnotationStatistics | null {
if (!cocoData) return null;
@@ -124,9 +124,15 @@ export function calculateAnnotationStatistics(
return {
totalAnnotations: imageAnnotations.length,
visibleAnnotations: visibleAnnotations.length,
- selectedAnnotations: selectedAnnotationIds.filter((id) =>
- imageAnnotations.some((ann) => ann.id === id)
- ).length,
+ selectedAnnotations: selectedAnnotationIds.filter((id) => {
+ // Handle both number and string IDs
+ if (typeof id === 'string') {
+ // For comparison mode unique IDs like "primary-123" or "comparison-456"
+ const numericId = id.includes('-') ? parseInt(id.split('-')[1]) : parseInt(id);
+ return imageAnnotations.some((ann) => ann.id === numericId);
+ }
+ return imageAnnotations.some((ann) => ann.id === id);
+ }).length,
categoryStats,
sizeStats,
coveragePercentage,