From e4eca6b303132dfa0dbce4c759cb28a2c6e92e4d Mon Sep 17 00:00:00 2001 From: yoshikazu Date: Thu, 23 Jul 2026 09:26:10 +0900 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20=E3=83=8E=E3=83=BC=E3=82=B3?= =?UTF-8?q?=E3=83=BC=E3=83=89=E7=94=BB=E5=83=8F=E6=A4=9C=E6=9F=BB=E3=82=A2?= =?UTF-8?q?=E3=83=97=E3=83=AA=20(MVP=20+=20=E8=BF=BD=E5=8A=A0=E6=A9=9F?= =?UTF-8?q?=E8=83=BD=E4=B8=80=E5=BC=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 検査レシピ=処理/検査ステップの縦リスト(配線なし)。yfoxlib(yfoximg/yfoxbase)使用。 - ビジュアル・パイプライン(カード+適用後サムネイル+緑/赤 合否) - テンプレート/対話式ウィザードでレシピ生成(CTaskDialog) - パラメータのスライダー・ライブ調整 - レシピ保存/読込(*.itr) + 最近使ったファイル(MRU) - 検査範囲(ROI)を複数マウス指定(ドラッグ追加/右クリック削除) - カードのドラッグ並べ替え - 判定結果画像の保存 + 検査ログCSV(項目拡張) - 処理6種+検査3種(明るさ/白画素率/塊の数) - /selftest(実行+レシピ往復+結果画像+複数ROI塊カウント検証)、/demo Co-Authored-By: Claude Opus 4.8 --- .gitignore | 31 ++ App.cpp | 59 +++ App.h | 16 + CLAUDE.md | 107 ++++++ ImgTestSystem.sln | 22 ++ ImgTestSystem.vcxproj | 106 +++++ MainFrame.cpp | 851 +++++++++++++++++++++++++++++++++++++++++ MainFrame.h | 128 +++++++ PipelineView.cpp | 372 ++++++++++++++++++ PipelineView.h | 52 +++ SelfTest.cpp | 208 ++++++++++ SelfTest.h | 7 + Wizard.cpp | 148 +++++++ Wizard.h | 10 + core/InspectEngine.cpp | 435 +++++++++++++++++++++ core/InspectEngine.h | 60 +++ core/Recipe.h | 81 ++++ core/RecipeStore.cpp | 134 +++++++ core/RecipeStore.h | 10 + core/StepCatalog.h | 168 ++++++++ core/Templates.h | 84 ++++ stdafx.cpp | 1 + stdafx.h | 28 ++ targetver.h | 2 + 24 files changed, 3120 insertions(+) create mode 100644 .gitignore create mode 100644 App.cpp create mode 100644 App.h create mode 100644 CLAUDE.md create mode 100644 ImgTestSystem.sln create mode 100644 ImgTestSystem.vcxproj create mode 100644 MainFrame.cpp create mode 100644 MainFrame.h create mode 100644 PipelineView.cpp create mode 100644 PipelineView.h create mode 100644 SelfTest.cpp create mode 100644 SelfTest.h create mode 100644 Wizard.cpp create mode 100644 Wizard.h create mode 100644 core/InspectEngine.cpp create mode 100644 core/InspectEngine.h create mode 100644 core/Recipe.h create mode 100644 core/RecipeStore.cpp create mode 100644 core/RecipeStore.h create mode 100644 core/StepCatalog.h create mode 100644 core/Templates.h create mode 100644 stdafx.cpp create mode 100644 stdafx.h create mode 100644 targetver.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..88c0e37 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# ===== ビルド成果物 ===== +bin/ +obj/ +.vs/ + +# ===== VS / MSBuild 生成物 ===== +*.user +*.aps +*.pch +*.pdb +*.idb +*.ipdb +*.iobj +*.tlog +*.log + +# ===== 自己テスト / デモ 出力(通常は bin 配下=bin/ で除外) ===== +selftest_*.bmp +selftest_*.png +selftest_*.txt +selftest_*.itr +demo_*.png +demo_*.bmp +diag_*.bmp +imgtest_demo.bmp +inspect_log.csv +result_*.png +logtest_out/ + +# Claude Code ローカル設定 +.claude/settings.local.json diff --git a/App.cpp b/App.cpp new file mode 100644 index 0000000..c13f994 --- /dev/null +++ b/App.cpp @@ -0,0 +1,59 @@ +// +// App.cpp - CWinApp。GDI+ 起動、/selftest の分岐、メインウィンドウ生成。 +// +#include "stdafx.h" +#include "App.h" +#include "MainFrame.h" +#include "SelfTest.h" + +CImgTestApp theApp; + +CImgTestApp::CImgTestApp() {} + +BOOL CImgTestApp::InitInstance() +{ + CWinApp::InitInstance(); + + // 設定(最近使ったレシピ 等)を HKCU\Software\YFox\ImgTestSystem に保存。 + SetRegistryKey(L"YFox"); + + // GDI+ 起動(CImage/CImageEx が JPEG/PNG 読込に使用)。 + Gdiplus::GdiplusStartupInput gdiInput; + Gdiplus::GdiplusStartup(&m_gdiplusToken, &gdiInput, NULL); + + // コマンドライン /selftest はヘッドレス自己検証を実行して終了。 + CString cmd = m_lpCmdLine; + if (cmd.MakeLower().Find(L"/selftest") >= 0) + { + int rc = selftest::Run(); + return FALSE; // UI を出さず終了(ExitInstance で GDI+ 停止) + (void)rc; + } + if (cmd.MakeLower().Find(L"/demosave") >= 0) + { + selftest::SaveDemoResult(L"demo_result.png"); + return FALSE; + } + + CMainFrame* pFrame = new CMainFrame; + if (!pFrame->Create(NULL, L"ImgTestSystem - ノーコード画像検査", + WS_OVERLAPPEDWINDOW, CFrameWnd::rectDefault, NULL, NULL)) + { + delete pFrame; + return FALSE; + } + m_pMainWnd = pFrame; + pFrame->ShowWindow(SW_SHOW); + pFrame->UpdateWindow(); + + if (cmd.Find(L"/demo") >= 0) + pFrame->LoadDemo(); + return TRUE; +} + +int CImgTestApp::ExitInstance() +{ + if (m_gdiplusToken) + Gdiplus::GdiplusShutdown(m_gdiplusToken); + return CWinApp::ExitInstance(); +} diff --git a/App.h b/App.h new file mode 100644 index 0000000..6ecd2f7 --- /dev/null +++ b/App.h @@ -0,0 +1,16 @@ +#pragma once +#include "stdafx.h" + +// アプリケーション本体。 +class CImgTestApp : public CWinApp +{ +public: + CImgTestApp(); + virtual BOOL InitInstance(); + virtual int ExitInstance(); + +private: + ULONG_PTR m_gdiplusToken = 0; +}; + +extern CImgTestApp theApp; diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b64beff --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,107 @@ +# CLAUDE.md + +This file provides guidance to Claude Code when working with code in this repository. + +## 概要 + +**ImgTestSystem** は yfoxlib(`yfoximg` / `yfoxbase`)を使う **ノーコード画像検査アプリ**(純MFC)。 +パラダイムは隣の `C:\debug\ImgChkSystem`(ブロックをワイヤで繋ぐノードエディタ)とは **意図的に別物**: + +> **検査レシピ = 上から下へ順に実行する「ステップ」の縦リスト**(配線なし)。 +> 料理のレシピ / 検査チェックリストのメタファ。ステップは2種: +> - **処理**(画像を加工): グレー化 / メディアン / 二値化(帯) / 大津 / ガンマ / コントラスト +> - **検査**(測って範囲判定→PASS/FAIL): 平均輝度 / 白画素率 + +UI: 左=ステップ縦リスト(追加/削除/↑↓)・右上=選択ステップのパラメータ入力・右下=作業画像プレビュー・ +上部=[画像を開く]/[実行]と総合判定バナー。ノーコード=ドロップダウンと数値入力のみ、コードもワイヤも書かない。 + +## ビルド + +VS2022 (v143 / MFC Dynamic / Release|x64 / /utf-8 / stdcpp17)。 + +```powershell +$msb="C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\amd64\MSBuild.exe" +& $msb ImgTestSystem.sln /p:Configuration=Release /p:Platform=x64 +``` + +- リンク: `yfoximg.lib;yfoxbase.lib;gdiplus.lib`(libdir `C:\debug\yfoxlib\x64\$(Configuration)`)。 +- include: `C:\debug\yfoxlib\yfoximg;...\yfoxbase;...\yfoxbase\include`。 +- PostBuild で `yfoximg.dll`/`yfoxbase.dll` を出力先へコピー。出力=`bin\x64\\`。 + +## アーキテクチャ + +UI非依存の **core/** と MFC UI層(ルート)に分離。 + +### core/(ウィンドウ非依存) +- `Recipe.h` — `StepKind`(処理/検査)、`StepParam`(数値パラメータ)、`Step`、`Recipe`(ステップの並び)。 +- `StepCatalog.h` — 種別→表示名 `StepName` / 検査判定 `IsInspect` / 既定パラメータ生成 `MakeStep` / パレット `AllStepKinds`。 + **新ステップ追加は `StepKind` に足し、この2関数(`StepName`/`MakeStep`)と `InspectEngine` の `RunStep` に1ケース足すだけ。** +- `InspectEngine.h/.cpp` — **唯一 yfoximg に依存する層**。`LoadImage`→`Run(recipe, upTo)` で作業画像を原画像から作り直し、 + 各ステップを `CImgProcessing`(`RGB2BW`/`Threshold`/`Threshold_Otsu`/`Avg`/`Gamma`/`Contrast`/`Median`)で適用。 + `upTo>=0` でその番号までプレビュー実行、`-1` で全実行し総合 PASS/FAIL を返す。`DrawWork` でプレビュー描画。 + +### UI層(ルート) +- `App.h/.cpp` — `CImgTestApp:CWinApp`。GDI+ 起動、`/selftest` 分岐、`CMainFrame` 生成。 +- `MainFrame.h/.cpp` — `CMainFrame:CFrameWnd`。子コントロールは **リソース不使用で OnCreate で動的生成**(.rc なし=RC の cp932 罠を回避)。 + リスト選択→パラメータ欄ロード+`PreviewUpTo`、[追加/削除/↑↓]でレシピ編集、[実行]で全実行+バナー更新。 +- `SelfTest.cpp` — `/selftest`(ヘッドレス)。合成画像→レシピ実行→`selftest_result.txt`(UTF-8)。 + +## yfoximg 作法(重要) + +- アンブレラ `yfoximg.h`(OpenGL 引き込み)は使わず **`ImgProcessing.h` を直接 include**。 +- yfoximg は **NOMINMAX を定義しない**(裸の `min`/`max` マクロ前提)。GDI+ も min/max マクロを要求するため、 + `ImgProcessing.h` を含む TU(`InspectEngine.cpp`/`SelfTest.cpp`)では **`std::min/std::max` を使わない**(手書きクランプ)。 +- 8bit グレー画像は `SetGrayPalette()` してから `Draw`/`Save`。処理系は R1(入力不変)・R2(出力は新規 `CDiBitmap`)。 +- 画像は 8/24/32bit を受理。`Ensure8` が 32→24(手動)→`RGB2BW` で 8bit へ正規化してから濃淡系処理を行う。 + +## 検証 + +- `ImgTestSystem.exe /selftest` → 合成画像(一様輝度128)で グレー化→平均輝度→白画素率 を実行し PASS を確認、 + `selftest_result.txt` を出力(終了コード 0=PASS)。 + +## わかりやすさ強化(3方式を統合) + +- **ビジュアル・パイプライン**(`PipelineView.*`, `CPipelineView:CWnd`): 各ステップをカード化し、 + 「そのステップ適用後のサムネイル」を縦に並べる=画像が上から下へ変化する様子が見える。 + 検査カードは大きな緑✓/赤✗バッジ+測定値。左ストライプ 青=処理/橙=検査。ダブルバッファ描画・ホイール/スクロール対応。 + **罠**: `CDiBitmap::Draw`(サムネ)は DC の `BkMode` を OPAQUE/暗色へ戻すので、直後の文字描画前に + `SetBkMode(TRANSPARENT)` を必ず再設定する(でないと暗地に明字で反転)。 +- **テンプレート**(`core/Templates.h` + `wizard::ChooseTemplate`): 目的(キズ/欠け/明るさ/文字)を選ぶと完成レシピが入る。 +- **対話式ウィザード**(`Wizard.*` + `CTaskDialog`): Q&A でレシピ生成。.rc 不要のため `CTaskDialog`(要 Common-Controls v6 マニフェスト=stdafx.h の pragma)。 +- 右パネル: 選択ステップのパラメータを **CSliderCtrl でライブ調整**(`OnHScroll`→即再実行→サムネ更新)。整数域は実値、 + ガンマ等の実数域は 0..1000 マッピング。合否バナーは `OnCtlColor` で緑/赤/灰に色分け。 +- 検査種別に **塊(キズ/穴)の数**(`InspectBlobCount`)を追加(`LabelingInit`/`Labeling`/`m_nLabelCount`)。 + +## 検証(追加) + +- `ImgTestSystem.exe /demo` → デモ画像(明るい地に暗い欠陥3点)+ キズ検査テンプレを読み込み実行(画面確認用)。 + スクリーンショットは `PrintWindow(PW_RENDERFULLCONTENT=2)` で捕捉可(Windows PowerShell 5.1 + System.Drawing)。 + +## レシピ保存/ROI/並べ替え/検査ログ(追加実装) + +- **レシピ保存/読込**(`core/RecipeStore.*`): 人が読める UTF-8 テキスト `*.itr`(`STEP ` / `PARAM ` / `ROI ...`)。 + `StepKindId`/`StepKindFromId`(`StepCatalog.h`)で enum を安定文字列 ID として永続化。読込は `MakeStep` 既定へキー一致で上書き(前方互換)。メニュー ファイル。 +- **検査範囲 ROI**(`Recipe::roiEnabled/roi`): 大プレビュー上をドラッグして矩形指定(`CMainFrame::OnLButtonDown/MouseMove/Up`)。 + 描画メソッド `DrawShot/DrawSource` は**実際に描いた矩形**(アスペクト維持=`AspectFit`)を返し、client↔画像座標の相互変換に使う。 + 検査は ROI 内で計測: 明るさ=`RectAvg(roi)` / 白画素率=`Threshold(...,roi)`÷ROI面積 / 塊数=`Labeling(...,roi)`。処理ステップは寸法不変なので全ステップ ROI 同座標系。ROI は大プレビュー+カードサムネに黄枠表示。メニュー 検査範囲。 +- **カードのドラッグ並べ替え**(`CPipelineView`): 押下→しきい値超で `m_dragging`、青い挿入インジケータ、離すと `WM_PV_MOVESTEP(from,slot)`→`CMainFrame::OnPipeMoveStep` が並べ替え+再実行。 +- **判定結果画像の保存 / 検査ログ**: `InspectEngine::SaveResultImage`=最終画像を24bit化し ROI枠+PASS/FAIL バナー+検査要約(縁取り文字)を焼き込み保存(拡張子で png/bmp/jpg)。 + 検査ログ=記録フォルダ(`SHBrowseForFolder`)へ `result_.png` 保存+`inspect_log.csv`(UTF-8 BOM, 日時/総合/画像/詳細)追記、フォルダを開く。メニュー 検査ログ。 +- **⚠ 罠**: Windows ヘッダの `#define small char`(rpcndr.h)のため、ローカル変数名に `small` を使うと C2628。`fSmall` 等にする。 + +## 複数ROI / MRU / CSV拡張(追加実装) + +- **複数ROI**(`Recipe::rois` = `vector`。空=画像全体): 大プレビューを**ドラッグで範囲追加**、**右クリックでその範囲を削除**、 + メニュー 検査範囲=「最後を取り消す」「すべてリセット」。検査は全ROIを合算(明るさ=面積重み付き平均 / 白画素率=合算/合計面積 / 塊数=全ROIマスクを結合→union bbox でラベリング)。 + 大プレビュー+カード+結果画像に黄枠+番号を描画。`.itr` は `ROI l t r b` を範囲ごとに1行(旧 `ROI enabled l t r b` 5値も読める)。 + - **⚠ 重要バグ修正**: yfoximg `CImgProcessing::Threshold` を**同一バッファへ sub-rect で複数回呼ぶとマスクが壊れる**(2個ROIで塊数0になった)。 + `MakeBinary` は Threshold をやめ、**画素を直接走査してROI内だけ白く塗る**方式に変更(8bit前提=Ensure8済)。`/selftest` に複数ROI塊カウント検証(全体3/大ROI2/小2個ROI2)を常設。 +- **最近使ったレシピ(MRU)**: 最大5件を `HKCU\Software\YFox\ImgTestSystem\Recent\File1..5` に保存(`SetRegistryKey(L"YFox")`)。 + ファイルメニューの「最近使ったレシピ」サブメニュー(`m_hRecentMenu` を保持し `UpdateRecentMenu` で再構築、`ON_COMMAND_RANGE`)。開く/保存時に `AddRecent`。存在しない項目はクリック時に一覧から除去提案。 +- **検査ログCSV 拡張**: 列= 日時,総合判定,元画像,レシピ,ステップ数,検査数,合格数,不合格数,検査範囲数,結果画像,詳細(UTF-8 BOM)。記録フォルダも `HKCU\...\Log\Folder` に永続化。 + +## 現状 + +処理6種+検査3種、ビジュアル・パイプライン、テンプレート、ウィザード、スライダー調整、 +レシピ保存/読込(+MRU)、複数ROI 指定、カードのドラッグ並べ替え、判定結果画像保存/検査ログ(CSV拡張)、/selftest・/demo。 +`/selftest` は 実行 + レシピ往復一致 + 結果画像保存 + 複数ROI塊カウント を検証(RESULT: PASS)。 diff --git a/ImgTestSystem.sln b/ImgTestSystem.sln new file mode 100644 index 0000000..dc5970d --- /dev/null +++ b/ImgTestSystem.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "ImgTestSystem", "ImgTestSystem.vcxproj", "{7A2B3C4D-5E6F-4A10-9B21-C3D4E5F60718}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|x64 = Debug|x64 + Release|x64 = Release|x64 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7A2B3C4D-5E6F-4A10-9B21-C3D4E5F60718}.Debug|x64.ActiveCfg = Debug|x64 + {7A2B3C4D-5E6F-4A10-9B21-C3D4E5F60718}.Debug|x64.Build.0 = Debug|x64 + {7A2B3C4D-5E6F-4A10-9B21-C3D4E5F60718}.Release|x64.ActiveCfg = Release|x64 + {7A2B3C4D-5E6F-4A10-9B21-C3D4E5F60718}.Release|x64.Build.0 = Release|x64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/ImgTestSystem.vcxproj b/ImgTestSystem.vcxproj new file mode 100644 index 0000000..e0f5ee4 --- /dev/null +++ b/ImgTestSystem.vcxproj @@ -0,0 +1,106 @@ + + + + + Debug + x64 + + + Release + x64 + + + + {7A2B3C4D-5E6F-4A10-9B21-C3D4E5F60718} + ImgTestSystem + 10.0 + MFCProj + + + + Application + v143 + Unicode + Dynamic + + + false + true + + + true + + + + $(ProjectDir)bin\$(Platform)\$(Configuration)\ + $(ProjectDir)obj\$(Platform)\$(Configuration)\ + false + + + + Level3 + stdcpp17 + Use + stdafx.h + $(ProjectDir);$(ProjectDir)core;C:\debug\yfoxlib\yfoximg;C:\debug\yfoxlib\yfoxbase;C:\debug\yfoxlib\yfoxbase\include;%(AdditionalIncludeDirectories) + WINVER=0x0A00;_WIN32_WINNT=0x0A00;%(PreprocessorDefinitions) + /utf-8 %(AdditionalOptions) + true + + + Windows + C:\debug\yfoxlib\x64\$(Configuration);%(AdditionalLibraryDirectories) + yfoximg.lib;yfoxbase.lib;gdiplus.lib;%(AdditionalDependencies) + + + copy /Y "C:\debug\yfoxlib\x64\$(Configuration)\yfoximg.dll" "$(OutDir)" +copy /Y "C:\debug\yfoxlib\x64\$(Configuration)\yfoxbase.dll" "$(OutDir)" + Copy yfoximg.dll / yfoxbase.dll next to ImgTestSystem.exe + + + + + MaxSpeed + MultiThreadedDLL + true + true + + + true + true + + + + + Disabled + MultiThreadedDebugDLL + + + + + + + + + + + + + + + + + + + Create + + + + + + + + + + + diff --git a/MainFrame.cpp b/MainFrame.cpp new file mode 100644 index 0000000..31442f7 --- /dev/null +++ b/MainFrame.cpp @@ -0,0 +1,851 @@ +// +// MainFrame.cpp - ノーコード画像検査(ビジュアル・パイプライン + テンプレ + ウィザード)。 +// 左: 各ステップをカード化し適用後サムネイルを縦に並べる(画像が変化する様子が見える)。 +// 右: 選択カードの設定をスライダーでライブ調整 / ステップ追加パレット。 +// 上: [画像を開く][テンプレート][ウィザード][実行] と 大きな合否バナー。 +// +#include "stdafx.h" +#include "MainFrame.h" +#include "core/StepCatalog.h" +#include "core/Templates.h" +#include "core/RecipeStore.h" +#include "Wizard.h" +#include "SelfTest.h" +#include // SHBrowseForFolder + +using namespace core; + +BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) + ON_WM_CREATE() + ON_WM_SIZE() + ON_WM_PAINT() + ON_WM_CTLCOLOR() + ON_WM_HSCROLL() + ON_WM_LBUTTONDOWN() + ON_WM_MOUSEMOVE() + ON_WM_LBUTTONUP() + ON_WM_RBUTTONDOWN() + ON_BN_CLICKED(IDC_BTN_OPEN, OnBtnOpen) + ON_BN_CLICKED(IDC_BTN_RUN, OnBtnRun) + ON_BN_CLICKED(IDC_BTN_ADD, OnBtnAdd) + ON_BN_CLICKED(IDC_BTN_DEL, OnBtnDel) + ON_BN_CLICKED(IDC_BTN_UP, OnBtnUp) + ON_BN_CLICKED(IDC_BTN_DOWN, OnBtnDown) + ON_BN_CLICKED(IDC_BTN_TMPL, OnBtnTemplate) + ON_BN_CLICKED(IDC_BTN_WIZ, OnBtnWizard) + ON_COMMAND(ID_FILE_OPENIMAGE, OnBtnOpen) + ON_COMMAND(ID_RECIPE_OPEN, OnRecipeOpen) + ON_COMMAND(ID_RECIPE_SAVE, OnRecipeSave) + ON_COMMAND(ID_APP_EXIT, OnAppExit) + ON_COMMAND(ID_ROI_CLEAR, OnRoiClear) + ON_COMMAND(ID_ROI_UNDO, OnRoiUndo) + ON_COMMAND(ID_RESULT_SAVE, OnResultSave) + ON_COMMAND(ID_LOG_SETFOLDER, OnLogSetFolder) + ON_COMMAND(ID_LOG_RECORD, OnLogRecord) + ON_COMMAND(ID_LOG_OPENFOLDER, OnLogOpenFolder) + ON_MESSAGE(WM_PV_SELCHANGED, OnPipeSelChanged) + ON_MESSAGE(WM_PV_MOVESTEP, OnPipeMoveStep) + ON_COMMAND_RANGE(ID_RECENT_BASE, ID_RECENT_BASE + RECENT_MAX - 1, OnRecentFile) +END_MESSAGE_MAP() + +void CMainFrame::BuildMenu() +{ + m_menu.CreateMenu(); + + CMenu file; file.CreatePopupMenu(); + file.AppendMenu(MF_STRING, ID_FILE_OPENIMAGE, L"画像を開く...\tCtrl+O"); + file.AppendMenu(MF_SEPARATOR); + file.AppendMenu(MF_STRING, ID_RECIPE_OPEN, L"レシピを開く..."); + file.AppendMenu(MF_STRING, ID_RECIPE_SAVE, L"レシピを保存...\tCtrl+S"); + { + CMenu recent; recent.CreatePopupMenu(); + m_hRecentMenu = recent.GetSafeHmenu(); + file.AppendMenu(MF_POPUP, (UINT_PTR)recent.Detach(), L"最近使ったレシピ"); + } + file.AppendMenu(MF_SEPARATOR); + file.AppendMenu(MF_STRING, ID_RESULT_SAVE, L"判定結果画像を保存..."); + file.AppendMenu(MF_SEPARATOR); + file.AppendMenu(MF_STRING, ID_APP_EXIT, L"終了"); + m_menu.AppendMenu(MF_POPUP, (UINT_PTR)file.Detach(), L"ファイル(&F)"); + + CMenu roi; roi.CreatePopupMenu(); + roi.AppendMenu(MF_STRING, ID_ROI_UNDO, L"最後の検査範囲を取り消す"); + roi.AppendMenu(MF_STRING, ID_ROI_CLEAR, L"すべてリセット(画像全体)"); + m_menu.AppendMenu(MF_POPUP, (UINT_PTR)roi.Detach(), L"検査範囲(&R)"); + + CMenu log; log.CreatePopupMenu(); + log.AppendMenu(MF_STRING, ID_LOG_SETFOLDER, L"記録フォルダを指定..."); + log.AppendMenu(MF_STRING, ID_LOG_RECORD, L"今の結果を記録する"); + log.AppendMenu(MF_STRING, ID_LOG_OPENFOLDER, L"記録フォルダを開く"); + m_menu.AppendMenu(MF_POPUP, (UINT_PTR)log.Detach(), L"検査ログ(&L)"); + + SetMenu(&m_menu); +} + +CMainFrame::CMainFrame() {} + +CString CMainFrame::FmtNum(double v) +{ + CString s; + if (v == (double)(long)v) s.Format(L"%ld", (long)v); + else s.Format(L"%.2f", v); + return s; +} + +int CMainFrame::OnCreate(LPCREATESTRUCT lpcs) +{ + if (CFrameWnd::OnCreate(lpcs) == -1) return -1; + + m_uiFont.CreatePointFont(100, L"Yu Gothic UI"); + m_bigFont.CreatePointFont(200, L"Yu Gothic UI"); + m_brPass.CreateSolidBrush(RGB(40, 170, 80)); + m_brFail.CreateSolidBrush(RGB(220, 60, 60)); + m_brNeutral.CreateSolidBrush(RGB(90, 100, 110)); + + DWORD bs = WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON; + m_btnOpen.Create(L"画像を開く…", bs, CRect(0, 0, 10, 10), this, IDC_BTN_OPEN); + m_btnTemplate.Create(L"テンプレートから作る", bs, CRect(0, 0, 10, 10), this, IDC_BTN_TMPL); + m_btnWizard.Create(L"ウィザードで作る", bs, CRect(0, 0, 10, 10), this, IDC_BTN_WIZ); + m_btnRun.Create(L"実行(検査する)", bs, CRect(0, 0, 10, 10), this, IDC_BTN_RUN); + m_result.Create(L"画像を開いて[実行]してください", + WS_CHILD | WS_VISIBLE | SS_CENTER | SS_CENTERIMAGE, CRect(0, 0, 10, 10), this, IDC_RESULT); + m_result.SetFont(&m_bigFont); + + for (CButton* b : { &m_btnOpen, &m_btnTemplate, &m_btnWizard, &m_btnRun }) + b->SetFont(&m_uiFont); + + // 左: パイプライン + m_pipe.Create(this, IDC_PIPE); + m_pipe.SetData(&m_recipe, &m_engine); + + // 右: 設定 + m_settingsTitle.Create(L"② 選んだステップの設定", WS_CHILD | WS_VISIBLE | SS_LEFT, CRect(0, 0, 10, 10), this, (UINT)-1); + m_settingsTitle.SetFont(&m_uiFont); + for (int i = 0; i < PARAM_ROWS; i++) + { + m_slabel[i].Create(L"", WS_CHILD | SS_LEFT | SS_CENTERIMAGE, CRect(0, 0, 10, 10), this, (UINT)-1); + m_slabel[i].SetFont(&m_uiFont); + m_slider[i].Create(WS_CHILD | WS_TABSTOP | TBS_HORZ | TBS_AUTOTICKS, CRect(0, 0, 10, 10), this, IDC_SLIDER0 + i); + m_sval[i].Create(L"", WS_CHILD | SS_LEFT | SS_CENTERIMAGE, CRect(0, 0, 10, 10), this, (UINT)-1); + m_sval[i].SetFont(&m_uiFont); + } + + // 右: 追加パレット + m_addTitle.Create(L"③ ステップを追加/並べ替え", WS_CHILD | WS_VISIBLE | SS_LEFT, CRect(0, 0, 10, 10), this, (UINT)-1); + m_addTitle.SetFont(&m_uiFont); + m_combo.Create(WS_CHILD | WS_VISIBLE | WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, CRect(0, 0, 10, 240), this, IDC_COMBO); + m_combo.SetFont(&m_uiFont); + int nk = 0; const StepKind* ks = AllStepKinds(nk); + for (int i = 0; i < nk; i++) + { + int item = m_combo.AddString(StepName(ks[i])); + m_combo.SetItemData(item, (DWORD_PTR)ks[i]); + } + m_combo.SetCurSel(0); + m_btnAdd.Create(L"追加", bs, CRect(0, 0, 10, 10), this, IDC_BTN_ADD); + m_btnDel.Create(L"削除", bs, CRect(0, 0, 10, 10), this, IDC_BTN_DEL); + m_btnUp.Create(L"↑ 上へ", bs, CRect(0, 0, 10, 10), this, IDC_BTN_UP); + m_btnDown.Create(L"↓ 下へ", bs, CRect(0, 0, 10, 10), this, IDC_BTN_DOWN); + for (CButton* b : { &m_btnAdd, &m_btnDel, &m_btnUp, &m_btnDown }) + b->SetFont(&m_uiFont); + + // 既定レシピ(明るさ確認の例) + m_recipe.steps.push_back(MakeStep(StepKind::Grayscale)); + { Step s = MakeStep(StepKind::InspectBrightness); s.SetParam(L"min", 80); s.SetParam(L"max", 180); m_recipe.steps.push_back(s); } + + BuildMenu(); + LoadRecent(); + UpdateRecentMenu(); + m_logFolder = AfxGetApp()->GetProfileString(L"Log", L"Folder", L""); + + m_pipe.SetSel(0); + ConfigureSliders(0); + + SetWindowText(L"ImgTestSystem - ノーコード画像検査"); + SetWindowPos(NULL, 0, 0, 1180, 780, SWP_NOMOVE | SWP_NOZORDER); + return 0; +} + +int CMainFrame::Sel() const { return const_cast(m_pipe).GetSel(); } + +void CMainFrame::OnSize(UINT nType, int cx, int cy) +{ + CFrameWnd::OnSize(nType, cx, cy); + LayoutControls(); +} + +void CMainFrame::LayoutControls() +{ + CRect cr; GetClientRect(&cr); + int cx = cr.Width(), cy = cr.Height(); + if (cx < 100 || cy < 100) return; + + const int M = 10; + // 上部バー(2段: ボタン行 + バナー) + int bw = 150, bh = 30, bx = M; + m_btnOpen.MoveWindow(bx, M, bw, bh); bx += bw + 8; + m_btnTemplate.MoveWindow(bx, M, bw + 20, bh); bx += bw + 28; + m_btnWizard.MoveWindow(bx, M, bw, bh); bx += bw + 8; + m_btnRun.MoveWindow(bx, M, bw, bh); bx += bw + 8; + int bannerX = bx + 6; + m_result.MoveWindow(bannerX, M, cx - bannerX - M, bh + 8); + + int top = M + bh + 12; + + // 左: パイプライン(右カラム幅を残す) + int rightW = 380; + if (cx - rightW - M * 3 < 360) rightW = cx - 360 - M * 3; + int leftW = cx - rightW - M * 3; + m_pipe.MoveWindow(M, top, leftW, cy - top - M); + + // 右カラム + int rx = M + leftW + M; + int rw = rightW + M; // 使える幅 + if (rx + rw > cx - M) rw = cx - M - rx; + + // ① 大プレビュー(正方形) + int pv = rw < (cy - top - 40) ? rw : 240; + if (pv > 300) pv = 300; + m_bigPreviewRect = CRect(rx, top + 20, rx + rw, top + 20 + pv); + // (プレビュー上のラベルは OnPaint で描く) + + // ② 設定スライダー + int sy = m_bigPreviewRect.bottom + 12; + m_settingsTitle.MoveWindow(rx, sy, rw, 20); sy += 24; + for (int i = 0; i < PARAM_ROWS; i++) + { + m_slabel[i].MoveWindow(rx, sy, rw - 70, 20); + m_sval[i].MoveWindow(rx + rw - 66, sy, 66, 20); + m_slider[i].MoveWindow(rx, sy + 20, rw, 26); + sy += 52; + } + + // ③ 追加パレット(下部) + int ay = cy - M - 92; + if (ay < sy + 6) ay = sy + 6; + m_addTitle.MoveWindow(rx, ay, rw, 20); ay += 24; + m_combo.MoveWindow(rx, ay, rw - 76, 240); + m_btnAdd.MoveWindow(rx + rw - 70, ay, 70, 26); ay += 32; + int q = (rw - 16) / 3; + m_btnDel.MoveWindow(rx, ay, q, 26); + m_btnUp.MoveWindow(rx + q + 8, ay, q, 26); + m_btnDown.MoveWindow(rx + 2 * q + 16, ay, rw - 2 * q - 16, 26); + + Invalidate(); +} + +void CMainFrame::OnPaint() +{ + CPaintDC dc(this); + + // プレビュー見出し + dc.SetBkMode(TRANSPARENT); + CFont* of = dc.SelectObject(&m_uiFont); + dc.SetTextColor(RGB(60, 70, 80)); + CRect lbl(m_bigPreviewRect.left, m_bigPreviewRect.top - 20, m_bigPreviewRect.right, m_bigPreviewRect.top); + int sel = Sel(); + CString cap = L"① プレビュー(ドラッグで検査範囲を追加/右クリックで削除)"; + if (sel >= 0 && sel < (int)m_recipe.steps.size()) + cap.Format(L"① ステップ%d: %s(ドラッグで範囲追加/右クリックで削除)", sel + 1, (LPCWSTR)StepName(m_recipe.steps[sel].kind)); + dc.DrawText(cap, lbl, DT_LEFT | DT_BOTTOM | DT_SINGLELINE | DT_END_ELLIPSIS); + dc.SelectObject(of); + + // プレビュー本体 + dc.FillSolidRect(m_bigPreviewRect, RGB(30, 30, 30)); + CRect inner = m_bigPreviewRect; inner.DeflateRect(3, 3); + CRect fit(0, 0, 0, 0); + if (m_engine.HasImage() && sel >= 0 && m_engine.HasShot(sel)) + fit = m_engine.DrawShot(&dc, inner, sel); + else if (m_engine.HasImage()) + fit = m_engine.DrawSource(&dc, inner); + else + { + dc.SetBkMode(TRANSPARENT); + dc.SetTextColor(RGB(200, 200, 200)); + dc.DrawText(L"[画像を開く]で画像を読み込みます", inner, DT_CENTER | DT_VCENTER | DT_WORDBREAK); + } + m_previewFitRect = fit; + + // 確定済み ROI(複数)を黄枠+番号で表示 + if (m_engine.HasImage() && !m_recipe.rois.empty() && !fit.IsRectEmpty()) + { + int sw = m_engine.SrcWidth(), sh = m_engine.SrcHeight(); + if (sw > 0 && sh > 0) + { + double kx = (double)fit.Width() / sw, ky = (double)fit.Height() / sh; + CPen yp(PS_SOLID, 2, RGB(255, 210, 0)); + CPen* oyp = dc.SelectObject(&yp); + dc.SelectStockObject(NULL_BRUSH); + dc.SetBkMode(TRANSPARENT); + dc.SetTextColor(RGB(255, 210, 0)); + dc.SelectObject(&m_uiFont); + for (size_t i = 0; i < m_recipe.rois.size(); i++) + { + const CRect& roi = m_recipe.rois[i]; + CRect r(fit.left + (int)(roi.left * kx), fit.top + (int)(roi.top * ky), + fit.left + (int)(roi.right * kx), fit.top + (int)(roi.bottom * ky)); + dc.Rectangle(r); + CString no; no.Format(L"%d", (int)i + 1); + dc.TextOut(r.left + 2, r.top, no); + } + dc.SelectObject(oyp); + } + } + + // ドラッグ中のラバーバンド + if (m_roiDragging) + { + CRect rb(m_roiStart, m_roiCur); rb.NormalizeRect(); + CPen dp(PS_DOT, 1, RGB(255, 255, 0)); + int obk = dc.SetBkMode(TRANSPARENT); + CPen* odp = dc.SelectObject(&dp); + dc.SelectStockObject(NULL_BRUSH); + dc.Rectangle(rb); + dc.SelectObject(odp); + dc.SetBkMode(obk); + } + + CPen pen(PS_SOLID, 1, RGB(120, 120, 120)); + CPen* op = dc.SelectObject(&pen); + dc.SelectStockObject(NULL_BRUSH); + dc.Rectangle(m_bigPreviewRect); + dc.SelectObject(op); +} + +// --- ROI マウス操作(大プレビュー上) --- +void CMainFrame::OnLButtonDown(UINT nFlags, CPoint pt) +{ + if (m_engine.HasImage() && !m_previewFitRect.IsRectEmpty() && m_previewFitRect.PtInRect(pt)) + { + m_roiDragging = true; + m_roiStart = m_roiCur = pt; + SetCapture(); + return; + } + CFrameWnd::OnLButtonDown(nFlags, pt); +} + +void CMainFrame::OnMouseMove(UINT nFlags, CPoint pt) +{ + if (m_roiDragging) + { + // プレビュー内にクランプ + CPoint p = pt; + if (p.x < m_previewFitRect.left) p.x = m_previewFitRect.left; + if (p.x > m_previewFitRect.right) p.x = m_previewFitRect.right; + if (p.y < m_previewFitRect.top) p.y = m_previewFitRect.top; + if (p.y > m_previewFitRect.bottom) p.y = m_previewFitRect.bottom; + m_roiCur = p; + InvalidateRect(m_bigPreviewRect, FALSE); + return; + } + CFrameWnd::OnMouseMove(nFlags, pt); +} + +void CMainFrame::OnLButtonUp(UINT nFlags, CPoint pt) +{ + if (m_roiDragging) + { + m_roiDragging = false; + ReleaseCapture(); + CRect rb(m_roiStart, m_roiCur); rb.NormalizeRect(); + CRect fit = m_previewFitRect; + int sw = m_engine.SrcWidth(), sh = m_engine.SrcHeight(); + if (rb.Width() >= 6 && rb.Height() >= 6 && sw > 0 && sh > 0 && !fit.IsRectEmpty()) + { + double kx = (double)sw / fit.Width(), ky = (double)sh / fit.Height(); + CRect r( + (int)((rb.left - fit.left) * kx), (int)((rb.top - fit.top) * ky), + (int)((rb.right - fit.left) * kx), (int)((rb.bottom - fit.top) * ky)); + if (r.left < 0) r.left = 0; if (r.top < 0) r.top = 0; + if (r.right > sw) r.right = sw; if (r.bottom > sh) r.bottom = sh; + m_recipe.rois.push_back(r); // 追加(複数指定) + RunAndRefresh(-2); + } + else + { + InvalidateRect(m_bigPreviewRect, FALSE); + } + return; + } + CFrameWnd::OnLButtonUp(nFlags, pt); +} + +// 右クリック: その位置を含む検査範囲を削除。 +void CMainFrame::OnRButtonDown(UINT nFlags, CPoint pt) +{ + if (m_engine.HasImage() && !m_previewFitRect.IsRectEmpty() && m_previewFitRect.PtInRect(pt) && !m_recipe.rois.empty()) + { + int sw = m_engine.SrcWidth(), sh = m_engine.SrcHeight(); + CRect fit = m_previewFitRect; + if (sw > 0 && sh > 0) + { + double kx = (double)sw / fit.Width(), ky = (double)sh / fit.Height(); + int ix = (int)((pt.x - fit.left) * kx), iy = (int)((pt.y - fit.top) * ky); + // 後に追加したもの(手前)から探す + for (int i = (int)m_recipe.rois.size() - 1; i >= 0; i--) + { + CRect rr = m_recipe.rois[i]; rr.NormalizeRect(); + if (rr.PtInRect(CPoint(ix, iy))) + { + m_recipe.rois.erase(m_recipe.rois.begin() + i); + RunAndRefresh(-2); + return; + } + } + } + } + CFrameWnd::OnRButtonDown(nFlags, pt); +} + +void CMainFrame::OnRoiClear() +{ + m_recipe.rois.clear(); + RunAndRefresh(-2); +} + +void CMainFrame::OnRoiUndo() +{ + if (m_recipe.rois.empty()) return; + m_recipe.rois.pop_back(); + RunAndRefresh(-2); +} + +// --- 判定結果画像の保存 / 検査ログ --- +void CMainFrame::OnResultSave() +{ + if (!m_engine.HasImage()) { AfxMessageBox(L"先に画像を開いてください"); return; } + CFileDialog dlg(FALSE, L"png", L"result.png", OFN_OVERWRITEPROMPT | OFN_HIDEREADONLY, + L"PNG (*.png)|*.png|BMP (*.bmp)|*.bmp|JPEG (*.jpg)|*.jpg||", this); + if (dlg.DoModal() != IDOK) return; + RunResult rr = m_engine.Run(m_recipe); + if (!m_engine.SaveResultImage(dlg.GetPathName(), m_recipe, rr)) + { AfxMessageBox(L"結果画像の保存に失敗しました"); return; } + AfxMessageBox(L"判定結果画像を保存しました:\n" + dlg.GetPathName(), MB_ICONINFORMATION); +} + +static CString BrowseFolder(CWnd* parent, LPCWSTR title) +{ + BROWSEINFO bi = {}; + bi.hwndOwner = parent->GetSafeHwnd(); + bi.lpszTitle = title; + bi.ulFlags = BIF_RETURNONLYFSDIRS | BIF_NEWDIALOGSTYLE; + LPITEMIDLIST pidl = SHBrowseForFolder(&bi); + if (!pidl) return L""; + CString path; + SHGetPathFromIDList(pidl, path.GetBuffer(MAX_PATH)); + path.ReleaseBuffer(); + CoTaskMemFree(pidl); + return path; +} + +static CString CsvField(const CString& s) +{ + if (s.Find(L',') < 0 && s.Find(L'"') < 0 && s.Find(L'\n') < 0) return s; + CString t = s; t.Replace(L"\"", L"\"\""); + return L"\"" + t + L"\""; +} + +void CMainFrame::OnLogSetFolder() +{ + CString f = BrowseFolder(this, L"検査ログの記録フォルダを選んでください"); + if (f.IsEmpty()) return; + m_logFolder = f; + AfxGetApp()->WriteProfileString(L"Log", L"Folder", m_logFolder); + AfxMessageBox(L"記録フォルダ:\n" + m_logFolder, MB_ICONINFORMATION); +} + +void CMainFrame::OnLogRecord() +{ + if (!m_engine.HasImage()) { AfxMessageBox(L"先に画像を開いてください"); return; } + if (m_logFolder.IsEmpty()) + { + OnLogSetFolder(); + if (m_logFolder.IsEmpty()) return; + } + RunResult rr = m_engine.Run(m_recipe); + + CTime now = CTime::GetCurrentTime(); + CString ts = now.Format(L"%Y%m%d_%H%M%S"); + CString imgName = L"result_" + ts + L".png"; + CString imgPath = m_logFolder + L"\\" + imgName; + m_engine.SaveResultImage(imgPath, m_recipe, rr); + + // 検査要約テキスト + 集計 + CString detail; + int inspectN = 0, okN = 0, ngN = 0; + for (const Step& st : m_recipe.steps) + { + if (!IsInspect(st.kind)) continue; + inspectN++; + if (st.result.ok) okN++; else ngN++; + if (!detail.IsEmpty()) detail += L" | "; + detail += StepName(st.kind) + L": " + st.result.text + (st.result.ok ? L"[OK]" : L"[NG]"); + } + CString overall = rr.error ? L"ERROR" : (rr.hasInspect ? (rr.pass ? L"PASS" : L"FAIL") : L"-"); + + // パスからファイル名だけ取り出す。 + auto fileName = [](const CString& p) -> CString { + if (p.IsEmpty()) return L""; + int s = p.ReverseFind(L'\\'); return (s >= 0) ? p.Mid(s + 1) : p; + }; + + // CSV 追記(無ければヘッダ付きで新規) + CString csvPath = m_logFolder + L"\\inspect_log.csv"; + bool exists = (::GetFileAttributes(csvPath) != INVALID_FILE_ATTRIBUTES); + CFile f; + if (f.Open(csvPath, CFile::modeCreate | CFile::modeNoTruncate | CFile::modeWrite)) + { + f.SeekToEnd(); + CString line; + if (!exists) + { + const unsigned char bom[3] = { 0xEF,0xBB,0xBF }; + f.Write(bom, 3); + line = L"日時,総合判定,元画像,レシピ,ステップ数,検査数,合格数,不合格数,検査範囲数,結果画像,詳細\r\n"; + } + CString stamp = now.Format(L"%Y-%m-%d %H:%M:%S"); + CString cols; + cols.Format(L"%d,%d,%d,%d,%d", (int)m_recipe.steps.size(), inspectN, okN, ngN, (int)m_recipe.rois.size()); + line += stamp + L"," + overall + L"," + + CsvField(fileName(m_imagePath)) + L"," + + CsvField(fileName(m_recipePath)) + L"," + + cols + L"," + + CsvField(imgName) + L"," + + CsvField(detail) + L"\r\n"; + int n = WideCharToMultiByte(CP_UTF8, 0, line, line.GetLength(), NULL, 0, NULL, NULL); + CStringA a; + WideCharToMultiByte(CP_UTF8, 0, line, line.GetLength(), a.GetBuffer(n), n, NULL, NULL); + a.ReleaseBuffer(n); + f.Write((LPCSTR)a, a.GetLength()); + f.Close(); + } + ShowResult(rr); + AfxMessageBox(L"記録しました:\n" + imgName + L"\n" + overall, MB_ICONINFORMATION); +} + +void CMainFrame::OnLogOpenFolder() +{ + if (m_logFolder.IsEmpty()) { AfxMessageBox(L"先に記録フォルダを指定してください"); return; } + ShellExecute(GetSafeHwnd(), L"open", m_logFolder, NULL, NULL, SW_SHOWNORMAL); +} + +HBRUSH CMainFrame::OnCtlColor(CDC* pDC, CWnd* pWnd, UINT nCtlColor) +{ + if (pWnd->GetDlgCtrlID() == IDC_RESULT) + { + pDC->SetTextColor(RGB(255, 255, 255)); + pDC->SetBkMode(TRANSPARENT); + switch (m_resultState) + { + case 1: return (HBRUSH)m_brPass; + case 2: return (HBRUSH)m_brFail; + default: return (HBRUSH)m_brNeutral; + } + } + return CFrameWnd::OnCtlColor(pDC, pWnd, nCtlColor); +} + +void CMainFrame::ShowResult(const RunResult& rr) +{ + m_resultState = rr.error ? 2 : (rr.hasInspect ? (rr.pass ? 1 : 2) : 0); + m_result.SetWindowText(rr.message); + m_result.Invalidate(); +} + +void CMainFrame::RunAndRefresh(int selectAfter) +{ + RunResult rr = m_engine.Run(m_recipe); + if (selectAfter != -2) + { + int s = selectAfter; + if (s < 0) s = 0; + if (s >= (int)m_recipe.steps.size()) s = (int)m_recipe.steps.size() - 1; + m_pipe.SetSel(s); + ConfigureSliders(s); + } + m_pipe.RefreshView(); + ShowResult(rr); + InvalidateRect(CRect(m_bigPreviewRect.left, m_bigPreviewRect.top - 22, m_bigPreviewRect.right + 2, m_bigPreviewRect.bottom + 2), TRUE); +} + +// --- スライダー構成 --- +void CMainFrame::ConfigureSliders(int idx) +{ + m_pCount = 0; + if (idx >= 0 && idx < (int)m_recipe.steps.size()) + { + const Step& st = m_recipe.steps[idx]; + m_pCount = (int)st.params.size(); + if (m_pCount > PARAM_ROWS) m_pCount = PARAM_ROWS; + CString t; t.Format(L"② 「%s」の設定", (LPCWSTR)StepName(st.kind)); + m_settingsTitle.SetWindowText(t); + + for (int i = 0; i < m_pCount; i++) + { + const StepParam& p = st.params[i]; + m_pLo[i] = p.lo; m_pHi[i] = p.hi; + bool integral = (p.lo == (double)(long)p.lo) && (p.hi == (double)(long)p.hi) && (p.hi - p.lo <= 1000); + m_pFloat[i] = !integral; + m_slabel[i].SetWindowText(p.label); + if (integral) + { + m_slider[i].SetRange((int)p.lo, (int)p.hi, TRUE); + m_slider[i].SetPos((int)p.value); + } + else + { + m_slider[i].SetRange(0, 1000, TRUE); + int pos = (int)((p.value - p.lo) / (p.hi - p.lo) * 1000.0 + 0.5); + m_slider[i].SetPos(pos); + } + m_sval[i].SetWindowText(FmtNum(p.value)); + m_slabel[i].ShowWindow(SW_SHOW); + m_slider[i].ShowWindow(SW_SHOW); + m_sval[i].ShowWindow(SW_SHOW); + } + } + else + { + m_settingsTitle.SetWindowText(L"② 選んだステップの設定"); + } + for (int i = m_pCount; i < PARAM_ROWS; i++) + { + m_slabel[i].ShowWindow(SW_HIDE); + m_slider[i].ShowWindow(SW_HIDE); + m_sval[i].ShowWindow(SW_HIDE); + } +} + +double CMainFrame::ReadSlider(int i) const +{ + int pos = const_cast(m_slider[i]).GetPos(); + if (m_pFloat[i]) return m_pLo[i] + (m_pHi[i] - m_pLo[i]) * pos / 1000.0; + return (double)pos; +} + +void CMainFrame::OnHScroll(UINT nSBCode, UINT nPos, CScrollBar* pBar) +{ + int sel = Sel(); + if (sel < 0 || sel >= (int)m_recipe.steps.size()) { CFrameWnd::OnHScroll(nSBCode, nPos, pBar); return; } + for (int i = 0; i < m_pCount; i++) + { + if (pBar && pBar->GetSafeHwnd() == m_slider[i].GetSafeHwnd()) + { + double v = ReadSlider(i); + m_recipe.steps[sel].params[i].value = v; + m_sval[i].SetWindowText(FmtNum(v)); + RunAndRefresh(-2); // 選択維持のままライブ再計算 + return; + } + } + CFrameWnd::OnHScroll(nSBCode, nPos, pBar); +} + +// --- コマンド --- +void CMainFrame::OnBtnOpen() +{ + CFileDialog dlg(TRUE, NULL, NULL, OFN_FILEMUSTEXIST | OFN_HIDEREADONLY, + L"画像 (*.bmp;*.jpg;*.jpeg;*.png;*.tif;*.tiff)|*.bmp;*.jpg;*.jpeg;*.png;*.tif;*.tiff|すべて (*.*)|*.*||", this); + if (dlg.DoModal() != IDOK) return; + if (!m_engine.LoadImage(dlg.GetPathName())) { AfxMessageBox(L"画像の読み込みに失敗しました"); return; } + m_imagePath = dlg.GetPathName(); + RunAndRefresh(Sel() < 0 ? 0 : Sel()); +} + +void CMainFrame::OnBtnRun() +{ + if (!m_engine.HasImage()) { AfxMessageBox(L"先に[画像を開く]で画像を読み込んでください"); return; } + RunAndRefresh(-2); +} + +void CMainFrame::OnBtnAdd() +{ + int sel = m_combo.GetCurSel(); + if (sel < 0) return; + StepKind k = (StepKind)m_combo.GetItemData(sel); + int at = Sel(); + int insertAt = (at < 0) ? (int)m_recipe.steps.size() : at + 1; + m_recipe.steps.insert(m_recipe.steps.begin() + insertAt, MakeStep(k)); + RunAndRefresh(insertAt); +} + +void CMainFrame::OnBtnDel() +{ + int at = Sel(); + if (at < 0 || at >= (int)m_recipe.steps.size()) return; + m_recipe.steps.erase(m_recipe.steps.begin() + at); + RunAndRefresh(at); +} + +void CMainFrame::OnBtnUp() +{ + int at = Sel(); + if (at <= 0) return; + std::swap(m_recipe.steps[at], m_recipe.steps[at - 1]); + RunAndRefresh(at - 1); +} + +void CMainFrame::OnBtnDown() +{ + int at = Sel(); + if (at < 0 || at >= (int)m_recipe.steps.size() - 1) return; + std::swap(m_recipe.steps[at], m_recipe.steps[at + 1]); + RunAndRefresh(at + 1); +} + +void CMainFrame::OnBtnTemplate() +{ + Recipe out; + if (!wizard::ChooseTemplate(this, out)) return; + m_recipe = out; + RunAndRefresh(0); +} + +void CMainFrame::OnBtnWizard() +{ + Recipe out; + if (!wizard::BuildWithWizard(this, out)) return; + m_recipe = out; + RunAndRefresh(0); +} + +void CMainFrame::LoadDemo() +{ + CString path; + ::GetTempPath(MAX_PATH, path.GetBuffer(MAX_PATH)); path.ReleaseBuffer(); + path += L"imgtest_demo.bmp"; + if (!selftest::MakeDemoImage(path)) return; + if (!m_engine.LoadImage(path)) return; + m_imagePath = path; + auto tps = AllTemplates(); + if (!tps.empty()) m_recipe = tps[0].recipe; // キズ・汚れ + // 例として2つの検査範囲(左上と下中央の欠陥)を設定=複数ROIのデモ(右の欠陥は除外)。 + m_recipe.rois.clear(); + m_recipe.rois.push_back(CRect(40, 40, 130, 115)); + m_recipe.rois.push_back(CRect(105, 140, 200, 225)); + RunAndRefresh((int)m_recipe.steps.size() - 1); +} + +void CMainFrame::OpenRecipeFile(const CString& path) +{ + Recipe r; + if (!LoadRecipe(path, r)) { AfxMessageBox(L"レシピの読み込みに失敗しました:\n" + path); return; } + m_recipe = r; + m_recipePath = path; + AddRecent(path); + RunAndRefresh(0); +} + +void CMainFrame::OnRecipeOpen() +{ + CFileDialog dlg(TRUE, L"itr", NULL, OFN_FILEMUSTEXIST | OFN_HIDEREADONLY, + L"検査レシピ (*.itr)|*.itr|すべて (*.*)|*.*||", this); + if (dlg.DoModal() != IDOK) return; + OpenRecipeFile(dlg.GetPathName()); +} + +void CMainFrame::OnRecipeSave() +{ + CFileDialog dlg(FALSE, L"itr", m_recipePath.IsEmpty() ? L"recipe.itr" : (LPCWSTR)m_recipePath, + OFN_OVERWRITEPROMPT | OFN_HIDEREADONLY, + L"検査レシピ (*.itr)|*.itr|すべて (*.*)|*.*||", this); + if (dlg.DoModal() != IDOK) return; + if (!SaveRecipe(dlg.GetPathName(), m_recipe)) { AfxMessageBox(L"レシピの保存に失敗しました"); return; } + m_recipePath = dlg.GetPathName(); + AddRecent(m_recipePath); + AfxMessageBox(L"レシピを保存しました:\n" + m_recipePath, MB_ICONINFORMATION); +} + +// --- 最近使ったレシピ(MRU) --- +void CMainFrame::LoadRecent() +{ + m_recent.clear(); + CWinApp* app = AfxGetApp(); + for (int i = 0; i < RECENT_MAX; i++) + { + CString key; key.Format(L"File%d", i + 1); + CString v = app->GetProfileString(L"Recent", key, L""); + if (!v.IsEmpty()) m_recent.push_back(v); + } +} + +void CMainFrame::SaveRecent() +{ + CWinApp* app = AfxGetApp(); + for (int i = 0; i < RECENT_MAX; i++) + { + CString key; key.Format(L"File%d", i + 1); + app->WriteProfileString(L"Recent", key, i < (int)m_recent.size() ? m_recent[i] : L""); + } +} + +void CMainFrame::AddRecent(const CString& path) +{ + // 既存重複を除去して先頭へ、最大 RECENT_MAX 件。 + for (int i = (int)m_recent.size() - 1; i >= 0; i--) + if (m_recent[i].CompareNoCase(path) == 0) m_recent.erase(m_recent.begin() + i); + m_recent.insert(m_recent.begin(), path); + if ((int)m_recent.size() > RECENT_MAX) m_recent.resize(RECENT_MAX); + SaveRecent(); + UpdateRecentMenu(); +} + +void CMainFrame::UpdateRecentMenu() +{ + CMenu* rm = CMenu::FromHandle(m_hRecentMenu); + if (!rm) return; + while (rm->GetMenuItemCount() > 0) rm->DeleteMenu(0, MF_BYPOSITION); + if (m_recent.empty()) + rm->AppendMenu(MF_STRING | MF_GRAYED, 0, L"(なし)"); + else + for (int i = 0; i < (int)m_recent.size(); i++) + { + CString disp; disp.Format(L"&%d %s", i + 1, (LPCWSTR)m_recent[i]); + rm->AppendMenu(MF_STRING, ID_RECENT_BASE + i, disp); + } + DrawMenuBar(); +} + +void CMainFrame::OnRecentFile(UINT id) +{ + int idx = (int)id - ID_RECENT_BASE; + if (idx < 0 || idx >= (int)m_recent.size()) return; + CString path = m_recent[idx]; + if (::GetFileAttributes(path) == INVALID_FILE_ATTRIBUTES) + { + if (AfxMessageBox(L"ファイルが見つかりません。一覧から削除しますか?\n" + path, MB_YESNO | MB_ICONWARNING) == IDYES) + { + m_recent.erase(m_recent.begin() + idx); + SaveRecent(); + UpdateRecentMenu(); + } + return; + } + OpenRecipeFile(path); +} + +void CMainFrame::OnAppExit() { PostMessage(WM_CLOSE); } + +LRESULT CMainFrame::OnPipeMoveStep(WPARAM w, LPARAM l) +{ + int from = (int)w, slot = (int)l; + int n = (int)m_recipe.steps.size(); + if (from < 0 || from >= n) return 0; + if (slot < 0) slot = 0; if (slot > n) slot = n; + Step s = m_recipe.steps[from]; + m_recipe.steps.erase(m_recipe.steps.begin() + from); + int dst = (slot > from) ? slot - 1 : slot; + if (dst < 0) dst = 0; if (dst > (int)m_recipe.steps.size()) dst = (int)m_recipe.steps.size(); + m_recipe.steps.insert(m_recipe.steps.begin() + dst, s); + RunAndRefresh(dst); + return 0; +} + +LRESULT CMainFrame::OnPipeSelChanged(WPARAM w, LPARAM) +{ + int idx = (int)w; + ConfigureSliders(idx); + InvalidateRect(CRect(m_bigPreviewRect.left, m_bigPreviewRect.top - 22, m_bigPreviewRect.right + 2, m_bigPreviewRect.bottom + 2), TRUE); + return 0; +} diff --git a/MainFrame.h b/MainFrame.h new file mode 100644 index 0000000..ade7666 --- /dev/null +++ b/MainFrame.h @@ -0,0 +1,128 @@ +#pragma once +#include "stdafx.h" +#include "core/Recipe.h" +#include "core/InspectEngine.h" +#include "PipelineView.h" + +#define IDC_BTN_OPEN 2001 +#define IDC_BTN_RUN 2002 +#define IDC_BTN_ADD 2003 +#define IDC_BTN_DEL 2004 +#define IDC_BTN_UP 2005 +#define IDC_BTN_DOWN 2006 +#define IDC_BTN_TMPL 2007 +#define IDC_BTN_WIZ 2008 +#define IDC_COMBO 2011 +#define IDC_PIPE 2020 +#define IDC_RESULT 2021 +#define IDC_SLIDER0 3001 // パラメータ スライダー(4本) +#define PARAM_ROWS 4 + +// メニュー コマンド ID +#define ID_FILE_OPENIMAGE 40001 +#define ID_RECIPE_OPEN 40002 +#define ID_RECIPE_SAVE 40003 +#define ID_APP_EXIT 40004 +#define ID_RESULT_SAVE 40010 +#define ID_ROI_CLEAR 40020 +#define ID_ROI_UNDO 40021 +#define ID_LOG_SETFOLDER 40030 +#define ID_LOG_RECORD 40031 +#define ID_LOG_OPENFOLDER 40032 +#define ID_RECENT_BASE 41000 +#define RECENT_MAX 5 + +// メインウィンドウ。左=ビジュアル・パイプライン / 右=選択ステップの調整(スライダー)+追加。 +class CMainFrame : public CFrameWnd +{ +public: + CMainFrame(); + void LoadDemo(); // /demo: デモ画像 + キズ検査テンプレートを読み込み実行 + +protected: + afx_msg int OnCreate(LPCREATESTRUCT lpcs); + afx_msg void OnSize(UINT nType, int cx, int cy); + afx_msg void OnPaint(); + afx_msg HBRUSH OnCtlColor(CDC* pDC, CWnd* pWnd, UINT nCtlColor); + afx_msg void OnHScroll(UINT nSBCode, UINT nPos, CScrollBar* pBar); + afx_msg void OnBtnOpen(); + afx_msg void OnBtnRun(); + afx_msg void OnBtnAdd(); + afx_msg void OnBtnDel(); + afx_msg void OnBtnUp(); + afx_msg void OnBtnDown(); + afx_msg void OnBtnTemplate(); + afx_msg void OnBtnWizard(); + afx_msg void OnRecipeOpen(); + afx_msg void OnRecipeSave(); + afx_msg void OnAppExit(); + afx_msg void OnRoiClear(); + afx_msg void OnRoiUndo(); + afx_msg void OnResultSave(); + afx_msg void OnLogSetFolder(); + afx_msg void OnLogRecord(); + afx_msg void OnLogOpenFolder(); + afx_msg void OnLButtonDown(UINT nFlags, CPoint pt); + afx_msg void OnMouseMove(UINT nFlags, CPoint pt); + afx_msg void OnLButtonUp(UINT nFlags, CPoint pt); + afx_msg void OnRButtonDown(UINT nFlags, CPoint pt); + afx_msg LRESULT OnPipeSelChanged(WPARAM w, LPARAM l); + afx_msg LRESULT OnPipeMoveStep(WPARAM w, LPARAM l); + afx_msg void OnRecentFile(UINT id); + DECLARE_MESSAGE_MAP() + +private: + void BuildMenu(); + void LoadRecent(); + void SaveRecent(); + void AddRecent(const CString& path); + void UpdateRecentMenu(); + void OpenRecipeFile(const CString& path); + void LayoutControls(); + void RunAndRefresh(int selectAfter = -2); // -2=選択維持 + void ConfigureSliders(int stepIdx); // 選択ステップ→スライダー構成 + double ReadSlider(int i) const; // スライダー値→パラメータ値 + void ShowResult(const core::RunResult& rr); + int Sel() const; + static CString FmtNum(double v); + + // 上部 + CButton m_btnOpen, m_btnTemplate, m_btnWizard, m_btnRun; + CStatic m_result; + // 左 + CPipelineView m_pipe; + // 右: 設定スライダー + CStatic m_settingsTitle; + CStatic m_slabel[PARAM_ROWS]; + CSliderCtrl m_slider[PARAM_ROWS]; + CStatic m_sval[PARAM_ROWS]; + // 右: 追加パレット + CStatic m_addTitle; + CComboBox m_combo; + CButton m_btnAdd, m_btnDel, m_btnUp, m_btnDown; + + CFont m_bigFont, m_uiFont; + CBrush m_brPass, m_brFail, m_brNeutral; + + CMenu m_menu; + CString m_recipePath; + CString m_logFolder; + CString m_imagePath; // 最後に開いた画像(CSV用) + std::vector m_recent; // 最近使ったレシピ + HMENU m_hRecentMenu = NULL; // 「最近使ったレシピ」サブメニュー + + CRect m_bigPreviewRect; + CRect m_previewFitRect; // プレビューで実際に画像が描かれた矩形(ROI 座標変換用) + bool m_roiDragging = false; + CPoint m_roiStart, m_roiCur; + int m_resultState = 0; // 0=neutral 1=pass 2=fail + + // スライダー ↔ パラメータ 変換情報 + int m_pCount = 0; + double m_pLo[PARAM_ROWS] = {}; + double m_pHi[PARAM_ROWS] = {}; + bool m_pFloat[PARAM_ROWS] = {}; + + core::Recipe m_recipe; + core::InspectEngine m_engine; +}; diff --git a/PipelineView.cpp b/PipelineView.cpp new file mode 100644 index 0000000..5608bb6 --- /dev/null +++ b/PipelineView.cpp @@ -0,0 +1,372 @@ +// +// PipelineView.cpp - ビジュアル・パイプライン(カード + サムネイル + 合否)。 +// +#include "stdafx.h" +#include "PipelineView.h" +#include "core/StepCatalog.h" + +using namespace core; + +BEGIN_MESSAGE_MAP(CPipelineView, CWnd) + ON_WM_PAINT() + ON_WM_ERASEBKGND() + ON_WM_LBUTTONDOWN() + ON_WM_MOUSEMOVE() + ON_WM_LBUTTONUP() + ON_WM_VSCROLL() + ON_WM_MOUSEWHEEL() + ON_WM_SIZE() +END_MESSAGE_MAP() + +CPipelineView::CPipelineView() {} + +BOOL CPipelineView::Create(CWnd* parent, UINT id) +{ + // 独自ウィンドウクラス(背景ブラシ・矢印カーソル)。 + LPCTSTR cls = AfxRegisterWndClass(CS_HREDRAW | CS_VREDRAW, + ::LoadCursor(NULL, IDC_ARROW), (HBRUSH)(COLOR_WINDOW + 1), NULL); + if (!CWnd::Create(cls, L"", WS_CHILD | WS_VISIBLE | WS_VSCROLL | WS_BORDER, + CRect(0, 0, 10, 10), parent, id)) + return FALSE; + + m_fTitle.CreatePointFont(115, L"Yu Gothic UI"); + m_fHint.CreatePointFont(90, L"Yu Gothic UI"); + m_fBadge.CreatePointFont(130, L"Yu Gothic UI"); + m_fNum.CreatePointFont(120, L"Yu Gothic UI"); + return TRUE; +} + +void CPipelineView::SetData(Recipe* recipe, InspectEngine* engine) +{ + m_recipe = recipe; + m_engine = engine; + RefreshView(); +} + +int CPipelineView::ContentHeight() const +{ + int n = m_recipe ? (int)m_recipe->steps.size() : 0; + return 8 + n * (CardHeight() + 8); +} + +CRect CPipelineView::CardRect(int i) const +{ + CRect cr; GetClientRect(&cr); + int x = 8, w = cr.Width() - 16; + int y = 8 + i * (CardHeight() + 8) - m_scrollY; + return CRect(x, y, x + w, y + CardHeight()); +} + +void CPipelineView::UpdateScroll() +{ + CRect cr; GetClientRect(&cr); + int content = ContentHeight(); + SCROLLINFO si = { sizeof(si) }; + si.fMask = SIF_RANGE | SIF_PAGE | SIF_POS; + si.nMin = 0; + si.nMax = content > 0 ? content - 1 : 0; + si.nPage = cr.Height(); + int maxPos = content - cr.Height(); + if (maxPos < 0) maxPos = 0; + if (m_scrollY > maxPos) m_scrollY = maxPos; + if (m_scrollY < 0) m_scrollY = 0; + si.nPos = m_scrollY; + SetScrollInfo(SB_VERT, &si, TRUE); +} + +void CPipelineView::RefreshView() +{ + if (m_sel >= 0 && m_recipe && m_sel >= (int)m_recipe->steps.size()) + m_sel = (int)m_recipe->steps.size() - 1; + UpdateScroll(); + Invalidate(); +} + +void CPipelineView::SetSel(int idx) +{ + m_sel = idx; + // 選択カードを見えるようにスクロール。 + if (m_recipe && idx >= 0 && idx < (int)m_recipe->steps.size()) + { + CRect cr; GetClientRect(&cr); + int top = 8 + idx * (CardHeight() + 8); + int bot = top + CardHeight(); + if (top - m_scrollY < 0) m_scrollY = top; + else if (bot - m_scrollY > cr.Height()) m_scrollY = bot - cr.Height(); + UpdateScroll(); + } + Invalidate(); +} + +BOOL CPipelineView::OnEraseBkgnd(CDC*) { return TRUE; } // ちらつき防止(OnPaintで塗る) + +void CPipelineView::OnSize(UINT nType, int cx, int cy) +{ + CWnd::OnSize(nType, cx, cy); + UpdateScroll(); + Invalidate(); +} + +void CPipelineView::OnPaint() +{ + CPaintDC dc(this); + CRect cr; GetClientRect(&cr); + + // ダブルバッファ。 + CDC mem; mem.CreateCompatibleDC(&dc); + CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, cr.Width(), cr.Height()); + CBitmap* pOld = mem.SelectObject(&bmp); + + mem.FillSolidRect(cr, RGB(244, 246, 248)); + + if (!m_recipe || m_recipe->steps.empty()) + { + mem.SetBkMode(TRANSPARENT); + mem.SetTextColor(RGB(140, 140, 140)); + CFont* of = mem.SelectObject(&m_fHint); + mem.DrawText(L"ステップがありません。\n上の[テンプレート]や[ウィザード]、右の[追加]で作成できます。", + cr, DT_CENTER | DT_VCENTER | DT_WORDBREAK); + mem.SelectObject(of); + } + else + { + for (int i = 0; i < (int)m_recipe->steps.size(); i++) + { + CRect rc = CardRect(i); + if (rc.bottom < 0 || rc.top > cr.Height()) continue; // 画面外 + DrawCard(&mem, i, rc); + } + + // ドラッグ中: 挿入位置インジケータ(青い太線) + if (m_dragging && m_dragFrom >= 0) + { + int slot = InsertSlotAt(m_dragY); + int unit = CardHeight() + 8; + int y = 8 + slot * unit - 4 - m_scrollY; + mem.FillSolidRect(CRect(6, y - 2, cr.Width() - 6, y + 2), RGB(60, 130, 220)); + // つまんでいるカードの番号を近くに表示 + mem.SetBkMode(TRANSPARENT); + mem.SelectObject(&m_fHint); + mem.SetTextColor(RGB(60, 130, 220)); + CString tip; tip.Format(L"ステップ%d をここへ移動", m_dragFrom + 1); + mem.DrawText(tip, CRect(12, y + 4, cr.Width() - 6, y + 24), DT_LEFT | DT_SINGLELINE); + } + } + + dc.BitBlt(0, 0, cr.Width(), cr.Height(), &mem, 0, 0, SRCCOPY); + mem.SelectObject(pOld); +} + +void CPipelineView::DrawCard(CDC* dc, int i, const CRect& rc) +{ + const Step& st = m_recipe->steps[i]; + bool inspect = IsInspect(st.kind); + bool sel = (i == m_sel); + + // 背景 + 枠 + COLORREF bg = sel ? RGB(225, 238, 252) : RGB(255, 255, 255); + dc->FillSolidRect(rc, bg); + CPen penBorder(PS_SOLID, sel ? 2 : 1, sel ? RGB(60, 130, 220) : RGB(210, 214, 218)); + CPen* op = dc->SelectObject(&penBorder); + CBrush* ob = (CBrush*)dc->SelectStockObject(NULL_BRUSH); + dc->Rectangle(rc); + dc->SelectObject(ob); + dc->SelectObject(op); + + // 左の色ストライプ(処理=青 / 検査=橙) + CRect stripe(rc.left, rc.top, rc.left + 6, rc.bottom); + dc->FillSolidRect(stripe, inspect ? RGB(240, 150, 40) : RGB(70, 130, 190)); + + dc->SetBkMode(TRANSPARENT); + + // 番号バッジ + int cx = rc.left + 32, cy = rc.top + 26, r = 15; + CBrush numBr(inspect ? RGB(240, 150, 40) : RGB(70, 130, 190)); + CBrush* obn = dc->SelectObject(&numBr); + CPen* opn = (CPen*)dc->SelectStockObject(NULL_PEN); + dc->Ellipse(cx - r, cy - r, cx + r, cy + r); + dc->SelectObject(opn); + dc->SelectObject(obn); + CString no; no.Format(L"%d", i + 1); + CFont* of = dc->SelectObject(&m_fNum); + dc->SetTextColor(RGB(255, 255, 255)); + CRect nr(cx - r, cy - r, cx + r, cy + r); + dc->DrawText(no, nr, DT_CENTER | DT_VCENTER | DT_SINGLELINE); + + // サムネイル域(右) + int thumbW = 132, thumbH = CardHeight() - 20; + CRect thumb(rc.right - thumbW - 10, rc.top + 10, rc.right - 10, rc.top + 10 + thumbH); + dc->FillSolidRect(thumb, RGB(30, 30, 30)); + if (m_engine && m_engine->HasShot(i)) + { + CRect inner = thumb; inner.DeflateRect(2, 2); + CRect fit = m_engine->DrawShot(dc, inner, i); + // ROI(複数)を黄枠でオーバーレイ + if (m_recipe && !m_recipe->rois.empty() && !fit.IsRectEmpty()) + { + int sw = m_engine->SrcWidth(), sh = m_engine->SrcHeight(); + if (sw > 0 && sh > 0) + { + double kx = (double)fit.Width() / sw, ky = (double)fit.Height() / sh; + CPen yp(PS_SOLID, 1, RGB(255, 210, 0)); + CPen* oyp = dc->SelectObject(&yp); + dc->SelectStockObject(NULL_BRUSH); + for (const CRect& roi : m_recipe->rois) + { + CRect rr(fit.left + (int)(roi.left * kx), fit.top + (int)(roi.top * ky), + fit.left + (int)(roi.right * kx), fit.top + (int)(roi.bottom * ky)); + dc->Rectangle(rr); + } + dc->SelectObject(oyp); + } + } + } + else + { + dc->SelectObject(&m_fHint); + dc->SetTextColor(RGB(160, 160, 160)); + dc->DrawText(L"実行前", thumb, DT_CENTER | DT_VCENTER | DT_SINGLELINE); + } + CPen tb(PS_SOLID, 1, RGB(120, 120, 120)); + CPen* opt = dc->SelectObject(&tb); + dc->SelectStockObject(NULL_BRUSH); + dc->Rectangle(thumb); + dc->SelectObject(opt); + + // テキスト域 + // ※ CDiBitmap::Draw(サムネイル)が DC の BkMode を OPAQUE/暗色に戻すため、 + // 文字描画の前に透過へ再設定する(でないと暗地に明字で反転して見える)。 + dc->SetBkMode(TRANSPARENT); + int tx = rc.left + 56; + int tw = thumb.left - tx - 10; + // タイトル + dc->SelectObject(&m_fTitle); + dc->SetTextColor(RGB(30, 40, 50)); + CRect tr(tx, rc.top + 10, tx + tw, rc.top + 34); + dc->DrawText(StepName(st.kind), tr, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS); + // 説明 + dc->SelectObject(&m_fHint); + dc->SetTextColor(RGB(110, 120, 130)); + CRect hr(tx, rc.top + 36, tx + tw, rc.top + 74); + dc->DrawText(StepHint(st.kind), hr, DT_LEFT | DT_WORDBREAK | DT_END_ELLIPSIS); + + // 検査ステップは合否バッジ + 測定値 + if (inspect) + { + bool ran = st.result.ran; + bool ok = st.result.ok; + CString badge = !ran ? L"未実行" : (ok ? L"✓ 合格" : L"✕ 不合格"); + COLORREF bc = !ran ? RGB(150, 150, 150) : (ok ? RGB(40, 170, 80) : RGB(220, 60, 60)); + CRect br(tx, rc.bottom - 30, tx + 96, rc.bottom - 6); + dc->FillSolidRect(br, bc); + dc->SelectObject(&m_fBadge); + dc->SetTextColor(RGB(255, 255, 255)); + dc->DrawText(badge, br, DT_CENTER | DT_VCENTER | DT_SINGLELINE); + // 測定値テキスト + if (ran && !st.result.text.IsEmpty()) + { + dc->SelectObject(&m_fHint); + dc->SetTextColor(RGB(70, 80, 90)); + CRect mr(tx + 104, rc.bottom - 30, thumb.left - 10, rc.bottom - 6); + dc->DrawText(st.result.text, mr, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS); + } + } + + dc->SelectObject(of); +} + +// クライアント Y から挿入スロット(0..n)を求める。 +int CPipelineView::InsertSlotAt(int y) const +{ + int n = m_recipe ? (int)m_recipe->steps.size() : 0; + int unit = CardHeight() + 8; + int slot = (int)((double)(y + m_scrollY - 8) / unit + 0.5); + if (slot < 0) slot = 0; + if (slot > n) slot = n; + return slot; +} + +void CPipelineView::OnLButtonDown(UINT, CPoint pt) +{ + if (!m_recipe) return; + for (int i = 0; i < (int)m_recipe->steps.size(); i++) + { + if (CardRect(i).PtInRect(pt)) + { + m_sel = i; + Invalidate(); + GetParent()->SendMessage(WM_PV_SELCHANGED, (WPARAM)i, 0); + // ドラッグ開始候補 + m_dragFrom = i; + m_dragging = false; + m_dragStart = pt; + SetCapture(); + return; + } + } +} + +void CPipelineView::OnMouseMove(UINT, CPoint pt) +{ + if (m_dragFrom < 0) return; + if (!m_dragging) + { + if (abs(pt.y - m_dragStart.y) + abs(pt.x - m_dragStart.x) > 5) + { + m_dragging = true; + SetCursor(::LoadCursor(NULL, IDC_SIZENS)); + } + } + if (m_dragging) + { + m_dragY = pt.y; + Invalidate(); + } +} + +void CPipelineView::OnLButtonUp(UINT, CPoint pt) +{ + if (m_dragFrom < 0) return; + int from = m_dragFrom; + bool wasDragging = m_dragging; + m_dragFrom = -1; + m_dragging = false; + if (GetCapture() == this) ReleaseCapture(); + + if (wasDragging) + { + int slot = InsertSlotAt(pt.y); + // 実質移動が無いケース(同じ位置)は無視 + if (slot != from && slot != from + 1) + GetParent()->SendMessage(WM_PV_MOVESTEP, (WPARAM)from, (LPARAM)slot); + else + Invalidate(); + } +} + +void CPipelineView::OnVScroll(UINT nSBCode, UINT nPos, CScrollBar*) +{ + CRect cr; GetClientRect(&cr); + int line = 24, page = cr.Height(); + int y = m_scrollY; + switch (nSBCode) + { + case SB_LINEUP: y -= line; break; + case SB_LINEDOWN: y += line; break; + case SB_PAGEUP: y -= page; break; + case SB_PAGEDOWN: y += page; break; + case SB_THUMBTRACK: + case SB_THUMBPOSITION: y = nPos; break; + } + m_scrollY = y; + UpdateScroll(); + Invalidate(); +} + +BOOL CPipelineView::OnMouseWheel(UINT, short zDelta, CPoint) +{ + m_scrollY -= (zDelta / WHEEL_DELTA) * 48; + UpdateScroll(); + Invalidate(); + return TRUE; +} diff --git a/PipelineView.h b/PipelineView.h new file mode 100644 index 0000000..5fc4929 --- /dev/null +++ b/PipelineView.h @@ -0,0 +1,52 @@ +#pragma once +#include "stdafx.h" +#include "core/Recipe.h" +#include "core/InspectEngine.h" + +// 親へ「カード選択が変わった」を通知(wParam = 選択index)。 +#define WM_PV_SELCHANGED (WM_APP + 11) +// 親へ「カードを移動した」を通知(wParam = 元index, lParam = 挿入スロット 0..n)。 +#define WM_PV_MOVESTEP (WM_APP + 12) + +// ビジュアル・パイプライン表示: 各ステップをカード化し、適用後サムネイル + 合否を縦に並べる。 +class CPipelineView : public CWnd +{ +public: + CPipelineView(); + BOOL Create(CWnd* parent, UINT id); + + void SetData(core::Recipe* recipe, core::InspectEngine* engine); + void RefreshView(); // スクロール範囲再計算 + 再描画 + void SetSel(int idx); + int GetSel() const { return m_sel; } + +protected: + afx_msg void OnPaint(); + afx_msg BOOL OnEraseBkgnd(CDC* pDC); + afx_msg void OnLButtonDown(UINT nFlags, CPoint pt); + afx_msg void OnMouseMove(UINT nFlags, CPoint pt); + afx_msg void OnLButtonUp(UINT nFlags, CPoint pt); + afx_msg void OnVScroll(UINT nSBCode, UINT nPos, CScrollBar* pBar); + afx_msg BOOL OnMouseWheel(UINT nFlags, short zDelta, CPoint pt); + afx_msg void OnSize(UINT nType, int cx, int cy); + DECLARE_MESSAGE_MAP() + +private: + int CardHeight() const { return 104; } + int ContentHeight() const; + CRect CardRect(int i) const; // クライアント座標(スクロール込み) + void UpdateScroll(); + void DrawCard(CDC* dc, int i, const CRect& rc); + + core::Recipe* m_recipe = nullptr; + core::InspectEngine* m_engine = nullptr; + int m_sel = -1; + int m_scrollY = 0; + // ドラッグ並べ替え + int m_dragFrom = -1; // 押下したカード(まだ移動と確定していない) + bool m_dragging = false; // しきい値を超えて移動中 + CPoint m_dragStart; + int m_dragY = 0; // 現在のマウスY(挿入位置インジケータ用) + int InsertSlotAt(int y) const; // クライアントYから挿入スロット 0..n + CFont m_fTitle, m_fHint, m_fBadge, m_fNum; +}; diff --git a/SelfTest.cpp b/SelfTest.cpp new file mode 100644 index 0000000..9f55962 --- /dev/null +++ b/SelfTest.cpp @@ -0,0 +1,208 @@ +// +// SelfTest.cpp - /selftest。合成画像を作り→検査レシピを実行→結果をファイル出力。 +// ビルドが通り、yfoximg 呼び出し(RGB2BW/Threshold/Avg)が実際に動くことを確認する。 +// +#include "stdafx.h" +#include "SelfTest.h" +#include "core/InspectEngine.h" +#include "core/StepCatalog.h" +#include "core/RecipeStore.h" +#include "core/Templates.h" +#include "ImgProcessing.h" // CDiBitmap 合成用 + +using namespace core; + +namespace { + +// UTF-8 でテキストファイルを書き出す。 +void WriteUtf8(LPCWSTR path, const CString& text) +{ + int n = WideCharToMultiByte(CP_UTF8, 0, text, text.GetLength(), NULL, 0, NULL, NULL); + CStringA utf8; + if (n > 0) + { + WideCharToMultiByte(CP_UTF8, 0, text, text.GetLength(), utf8.GetBuffer(n), n, NULL, NULL); + utf8.ReleaseBuffer(n); + } + CFile f; + if (f.Open(path, CFile::modeCreate | CFile::modeWrite)) + { + if (!utf8.IsEmpty()) f.Write((LPCSTR)utf8, utf8.GetLength()); + f.Close(); + } +} + +// 一様輝度 val の 24bit 合成画像を作り BMP 保存。 +bool MakeInputBmp(LPCWSTR path, int val) +{ + CDiBitmap img; + if (!img.Create(64, 64, 24)) return false; + int W = img.GetWidth(), H = img.GetHeight(), pitch = img.GetPitch(); + LPBYTE bits = img.GetBits(); + for (int y = 0; y < H; y++) + { + BYTE* row = bits + (intptr_t)y * pitch; + for (int x = 0; x < W * 3; x++) row[x] = (BYTE)val; + } + return img.Save(CString(path)); +} + +} // namespace + +// デモ画像: 320x240 の明るい地(180)に、暗い欠陥(40)を3点描いた 24bit BMP。 +bool selftest::MakeDemoImage(LPCWSTR path) +{ + CDiBitmap img; + if (!img.Create(320, 240, 24)) return false; + int W = img.GetWidth(), H = img.GetHeight(), pitch = img.GetPitch(); + LPBYTE bits = img.GetBits(); + for (int y = 0; y < H; y++) + { + BYTE* row = bits + (intptr_t)y * pitch; + for (int x = 0; x < W; x++) { row[x * 3] = 180; row[x * 3 + 1] = 180; row[x * 3 + 2] = 180; } + } + // 暗い欠陥(塗りつぶし円)を3点。 + struct Spot { int cx, cy, r; } spots[] = { {80,70,14}, {210,120,10}, {150,180,16} }; + for (const Spot& s : spots) + for (int y = s.cy - s.r; y <= s.cy + s.r; y++) + { + if (y < 0 || y >= H) continue; + BYTE* row = bits + (intptr_t)y * pitch; + for (int x = s.cx - s.r; x <= s.cx + s.r; x++) + { + if (x < 0 || x >= W) continue; + int dx = x - s.cx, dy = y - s.cy; + if (dx * dx + dy * dy <= s.r * s.r) { row[x * 3] = 40; row[x * 3 + 1] = 40; row[x * 3 + 2] = 40; } + } + } + return img.Save(CString(path)); +} + +bool selftest::SaveDemoResult(LPCWSTR path) +{ + const wchar_t* in = L"demosave_input.bmp"; + if (!MakeDemoImage(in)) return false; + InspectEngine engine; + if (!engine.LoadImage(in)) return false; + auto tps = AllTemplates(); + if (tps.empty()) return false; + Recipe r = tps[0].recipe; // キズ・汚れ + r.rois.push_back(CRect(40, 40, 130, 115)); // 複数の検査範囲 + r.rois.push_back(CRect(105, 140, 200, 225)); + RunResult rr = engine.Run(r); + return engine.SaveResultImage(path, r, rr); +} + +int selftest::Run() +{ + CString log; + log += L"=== ImgTestSystem /selftest ===\r\n"; + + const wchar_t* inPath = L"selftest_input.bmp"; + if (!MakeInputBmp(inPath, 128)) + { + log += L"FAIL: 合成画像の作成に失敗\r\n"; + WriteUtf8(L"selftest_result.txt", log); + return 1; + } + log += L"合成画像 selftest_input.bmp (64x64, 一様輝度128) を作成\r\n"; + + InspectEngine engine; + if (!engine.LoadImage(inPath)) + { + log += L"FAIL: 画像読込に失敗\r\n"; + WriteUtf8(L"selftest_result.txt", log); + return 1; + } + log.AppendFormat(L"読込 OK: %dx%d %dbpp\r\n", engine.SrcWidth(), engine.SrcHeight(), engine.SrcBpp()); + + // レシピ: グレー化 → 検査(平均輝度 50〜200) → 検査(白画素率 0〜100%, thr100) + Recipe recipe; + recipe.steps.push_back(MakeStep(StepKind::Grayscale)); + { + Step s = MakeStep(StepKind::InspectBrightness); + s.SetParam(L"min", 50); s.SetParam(L"max", 200); + recipe.steps.push_back(s); + } + { + Step s = MakeStep(StepKind::InspectWhiteRatio); + s.SetParam(L"thresh", 100); s.SetParam(L"min", 0); s.SetParam(L"max", 100); + recipe.steps.push_back(s); + } + + RunResult rr = engine.Run(recipe); + + for (int i = 0; i < (int)recipe.steps.size(); i++) + { + const Step& st = recipe.steps[i]; + log.AppendFormat(L" #%d %s: %s\r\n", i + 1, (LPCWSTR)StepName(st.kind), (LPCWSTR)st.result.text); + } + log += rr.message + L"\r\n"; + + // レシピ保存/読込の往復一致(ステップ数 + パラメータ値)。 + bool roundtrip = false; + { + recipe.rois.clear(); + recipe.rois.push_back(CRect(5, 6, 40, 50)); + recipe.rois.push_back(CRect(12, 14, 30, 44)); + if (SaveRecipe(L"selftest_recipe.itr", recipe)) + { + Recipe r2; + if (LoadRecipe(L"selftest_recipe.itr", r2) + && r2.steps.size() == recipe.steps.size() + && r2.rois.size() == recipe.rois.size() + && (r2.rois.empty() || r2.rois[0] == recipe.rois[0]) + && (r2.rois.size() < 2 || r2.rois[1] == recipe.rois[1])) + { + roundtrip = true; + for (size_t i = 0; i < recipe.steps.size(); i++) + { + if (r2.steps[i].kind != recipe.steps[i].kind) { roundtrip = false; break; } + for (const auto& p : recipe.steps[i].params) + if (r2.steps[i].Param(p.key, -999) != p.value) { roundtrip = false; break; } + } + } + } + } + log.AppendFormat(L"レシピ保存/読込 往復一致: %s\r\n", roundtrip ? L"OK" : L"NG"); + + // 判定結果画像の保存(注釈付き)。 + bool imgSaved = engine.SaveResultImage(L"selftest_result.png", recipe, rr); + if (imgSaved) + { + CFileStatus fs; + imgSaved = CFile::GetStatus(L"selftest_result.png", fs) && fs.m_size > 0; + } + log.AppendFormat(L"判定結果画像の保存: %s\r\n", imgSaved ? L"OK" : L"NG"); + + // --- 複数ROI 塊カウントの検証 --- + bool g_blobOk = false; + { + MakeDemoImage(L"diag_input.bmp"); + auto blobCount = [](std::vector rois) -> int { + InspectEngine e; e.LoadImage(L"diag_input.bmp"); + Recipe r; + r.steps.push_back(MakeStep(StepKind::Grayscale)); + r.steps.push_back(MakeStep(StepKind::Otsu)); + { Step s = MakeStep(StepKind::InspectBlobCount); + s.SetParam(L"thresh", 128); s.SetParam(L"invert", 1); + s.SetParam(L"min", 0); s.SetParam(L"max", 100); r.steps.push_back(s); } + r.rois = rois; + e.Run(r); + return (int)r.steps.back().result.measured; + }; + int cAll = blobCount({}); + int cBig = blobCount({ CRect(10,10,185,230) }); + int cTwo = blobCount({ CRect(40,40,130,115), CRect(105,140,200,225) }); + g_blobOk = (cAll == 3 && cBig == 2 && cTwo == 2); + log.AppendFormat(L"複数ROI 塊カウント: 全体=%d / 大ROI1個=%d / 小ROI2個=%d → %s\r\n", + cAll, cBig, cTwo, g_blobOk ? L"OK" : L"NG"); + } + + // 期待: 輝度128は[50,200]内でPASS。総合PASSかつエラー無しを合格条件とする。 + bool ok = rr.pass && !rr.error && roundtrip && imgSaved && g_blobOk; + log += ok ? L"RESULT: PASS\r\n" : L"RESULT: FAIL\r\n"; + + WriteUtf8(L"selftest_result.txt", log); + return ok ? 0 : 1; +} diff --git a/SelfTest.h b/SelfTest.h new file mode 100644 index 0000000..e0d1ed6 --- /dev/null +++ b/SelfTest.h @@ -0,0 +1,7 @@ +#pragma once +// SelfTest.h - コマンドライン /selftest 用のヘッドレス自己検証。 +namespace selftest { + int Run(); // 0=PASS, 1=FAIL。selftest_result.txt を出力。 + bool MakeDemoImage(LPCWSTR path); // デモ用: 明るい地に暗い欠陥数点の 24bit BMP を作る。 + bool SaveDemoResult(LPCWSTR path); // デモ画像+キズ検査+ROI の注釈付き結果画像を保存。 +} diff --git a/Wizard.cpp b/Wizard.cpp new file mode 100644 index 0000000..f9ef829 --- /dev/null +++ b/Wizard.cpp @@ -0,0 +1,148 @@ +// +// Wizard.cpp - 対話式ウィザード & 目的テンプレート選択(CTaskDialog ベース、.rc 不要)。 +// +#include "stdafx.h" +#include "Wizard.h" +#include "core/Templates.h" +#include "core/StepCatalog.h" + +using namespace core; + +namespace { + +// コマンドリンク付き質問を1つ出し、選ばれた ID を返す(キャンセル=0)。 +struct Choice { int id; LPCWSTR caption; }; + +int AskChoices(CWnd* parent, LPCWSTR title, LPCWSTR main, LPCWSTR content, + const Choice* choices, int n) +{ + if (!CTaskDialog::IsSupported()) + { + AfxMessageBox(L"この環境ではウィザードを表示できません。"); + return 0; + } + CTaskDialog dlg(content, main, title, + TDCBF_CANCEL_BUTTON, + TDF_USE_COMMAND_LINKS | TDF_ALLOW_DIALOG_CANCELLATION); + for (int i = 0; i < n; i++) + dlg.AddCommandControl(choices[i].id, choices[i].caption); + INT_PTR r = dlg.DoModal(parent ? parent->GetSafeHwnd() : NULL); + for (int i = 0; i < n; i++) + if (r == choices[i].id) return choices[i].id; + return 0; // キャンセル +} + +// 検査ステップ(末尾想定)の min/max を書き換える。 +void SetInspectRange(Recipe& rc, double lo, double hi) +{ + for (auto it = rc.steps.rbegin(); it != rc.steps.rend(); ++it) + { + if (IsInspect(it->kind)) { it->SetParam(L"min", lo); it->SetParam(L"max", hi); return; } + } +} + +} // namespace + +bool wizard::ChooseTemplate(CWnd* parent, Recipe& out) +{ + auto tps = AllTemplates(); + std::vector ch; + std::vector caps; caps.reserve(tps.size()); + for (size_t i = 0; i < tps.size(); i++) + { + caps.push_back(tps[i].name + L"\n" + tps[i].desc); + } + for (size_t i = 0; i < tps.size(); i++) + ch.push_back({ (int)(2001 + i), (LPCWSTR)caps[i] }); + + int r = AskChoices(parent, L"テンプレートから作る", + L"検査の目的を選んでください", + L"選ぶと、その目的に合った検査レシピが自動で入ります。あとでスライダーで微調整できます。", + ch.data(), (int)ch.size()); + if (r == 0) return false; + int idx = r - 2001; + if (idx < 0 || idx >= (int)tps.size()) return false; + out = tps[idx].recipe; + return true; +} + +bool wizard::BuildWithWizard(CWnd* parent, Recipe& out) +{ + // Q1: 何を検査? + Choice q1[] = { + { 1, L"キズ・汚れを見つける\n表面に傷や汚れが無いか調べます" }, + { 2, L"欠け・穴の数を数える\n穴やパターンの数が想定どおりか調べます" }, + { 3, L"明るさを確認する\n全体の明るさが基準内か調べます" }, + { 4, L"文字・印字の有無を見る\n印字・マークがあるか(無いか)調べます" }, + }; + int a1 = AskChoices(parent, L"検査ウィザード (1/2)", + L"何を検査したいですか?", L"目的に合わせて質問します。", q1, 4); + if (a1 == 0) return false; + + auto tps = AllTemplates(); // [0]キズ [1]欠け [2]明るさ [3]文字 + + if (a1 == 1) // キズ・汚れ + { + out = tps[0].recipe; + Choice q2[] = { + { 10, L"厳しい:キズは1個も許さない(0個で合格)" }, + { 11, L"ふつう:小さなキズ1個までは許容(1個まで合格)" }, + { 12, L"ゆるい:3個まで許容(3個まで合格)" }, + }; + int a2 = AskChoices(parent, L"検査ウィザード (2/2)", + L"どのくらい厳しく判定しますか?", L"許容するキズの数を選びます。", q2, 3); + if (a2 == 0) return false; + double mx = (a2 == 10) ? 0 : (a2 == 11) ? 1 : 3; + SetInspectRange(out, 0, mx); + return true; + } + if (a1 == 2) // 欠け・穴の数 + { + out = tps[1].recipe; + Choice q2[] = { + { 20, L"ちょうど1個なら合格" }, + { 21, L"1〜2個なら合格" }, + { 22, L"1〜4個なら合格" }, + { 23, L"あとで自分で決める(1〜4個で仮設定)" }, + }; + int a2 = AskChoices(parent, L"検査ウィザード (2/2)", + L"いくつなら合格にしますか?", L"想定される穴/塊の数を選びます。", q2, 4); + if (a2 == 0) return false; + double lo = 1, hi = 4; + if (a2 == 20) { lo = 1; hi = 1; } + else if (a2 == 21) { lo = 1; hi = 2; } + else { lo = 1; hi = 4; } + SetInspectRange(out, lo, hi); + return true; + } + if (a1 == 3) // 明るさ + { + out = tps[2].recipe; + Choice q2[] = { + { 30, L"暗すぎ・明るすぎを弾く(80〜180)" }, + { 31, L"明るめ重視(120〜220)" }, + { 32, L"暗め重視(40〜140)" }, + }; + int a2 = AskChoices(parent, L"検査ウィザード (2/2)", + L"どの明るさなら合格ですか?", L"合格とする平均の明るさの範囲を選びます。", q2, 3); + if (a2 == 0) return false; + if (a2 == 30) SetInspectRange(out, 80, 180); + else if (a2 == 31) SetInspectRange(out, 120, 220); + else SetInspectRange(out, 40, 140); + return true; + } + // a1 == 4 文字・印字 + { + out = tps[3].recipe; + Choice q2[] = { + { 40, L"印字が「ある」ことを確認したい(あれば合格)" }, + { 41, L"印字が「ない」ことを確認したい(無ければ合格)" }, + }; + int a2 = AskChoices(parent, L"検査ウィザード (2/2)", + L"どちらを合格にしますか?", L"印字の有無どちらを合格とするか選びます。", q2, 2); + if (a2 == 0) return false; + if (a2 == 40) SetInspectRange(out, 2, 100); // 2%以上インクあり + else SetInspectRange(out, 0, 1); // ほぼ無し + return true; + } +} diff --git a/Wizard.h b/Wizard.h new file mode 100644 index 0000000..8b74fc0 --- /dev/null +++ b/Wizard.h @@ -0,0 +1,10 @@ +#pragma once +#include "stdafx.h" +#include "core/Recipe.h" + +namespace wizard { + // 対話式ウィザードでレシピを作る。作成したら true。 + bool BuildWithWizard(CWnd* parent, core::Recipe& out); + // 目的テンプレートを選んでレシピを作る。選んだら true。 + bool ChooseTemplate(CWnd* parent, core::Recipe& out); +} diff --git a/core/InspectEngine.cpp b/core/InspectEngine.cpp new file mode 100644 index 0000000..74b11a0 --- /dev/null +++ b/core/InspectEngine.cpp @@ -0,0 +1,435 @@ +// +// InspectEngine.cpp - レシピを yfoximg で実行(唯一 yfoximg に依存する実装ファイル)。 +// +// 注意(yfoxlib の作法): +// - アンブレラの yfoximg.h(OpenGL 引き込み)は使わず ImgProcessing.h を直接 include。 +// - yfoximg は NOMINMAX を定義しない(裸の min/max マクロ前提)。この TU では +// std::min/std::max を使わず、手書きのクランプで済ませる。 +// +#include "stdafx.h" +#include "InspectEngine.h" +#include "StepCatalog.h" +#include "ImgProcessing.h" // CImgProcessing / CDiBitmap + +namespace core { + +static int ClampI(int v, int lo, int hi) { return v < lo ? lo : (v > hi ? hi : v); } + +InspectEngine::InspectEngine() {} +InspectEngine::~InspectEngine() {} + +bool InspectEngine::HasImage() const { return m_src && !m_src->IsNull(); } +int InspectEngine::SrcWidth() const { return m_src ? m_src->GetWidth() : 0; } +int InspectEngine::SrcHeight() const { return m_src ? m_src->GetHeight() : 0; } +int InspectEngine::SrcBpp() const { return m_src ? m_src->GetBPP() : 0; } +int InspectEngine::WorkWidth() const { return m_work ? m_work->GetWidth() : 0; } +int InspectEngine::WorkHeight() const { return m_work ? m_work->GetHeight() : 0; } +int InspectEngine::WorkBpp() const { return m_work ? m_work->GetBPP() : 0; } + +bool InspectEngine::LoadImage(LPCWSTR path) +{ + auto img = std::make_unique(); + if (!img->Load(CString(path), 0) || img->IsNull()) + return false; + m_src = std::move(img); + m_shots.clear(); + return true; +} + +// 32bit(アルファ付き)を 24bit へ落とす。 +static std::unique_ptr Down32To24(CDiBitmap* src) +{ + int W = src->GetWidth(), H = src->GetHeight(); + auto dst = std::make_unique(); + dst->Create(W, H, 24); + int sp = src->GetPitch(), dp = dst->GetPitch(); + LPBYTE sb = src->GetBits(), db = dst->GetBits(); + for (int y = 0; y < H; y++) + { + BYTE* s = sb + (intptr_t)y * sp; + BYTE* d = db + (intptr_t)y * dp; + for (int x = 0; x < W; x++) + { + d[x * 3 + 0] = s[x * 4 + 0]; + d[x * 3 + 1] = s[x * 4 + 1]; + d[x * 3 + 2] = s[x * 4 + 2]; + } + } + return dst; +} + +// m_work を 8bit グレーに保証する。 +static bool Ensure8(std::unique_ptr& work, CImgProcessing& ip) +{ + if (!work) return false; + int bpp = work->GetBPP(); + if (bpp == 8) return true; + if (bpp == 32) work = Down32To24(work.get()); + if (work->GetBPP() != 24) return false; + + int W = work->GetWidth(), H = work->GetHeight(); + auto gray = std::make_unique(); + gray->Create(W, H, 8); + if (!ip.RGB2BW(gray.get(), work.get())) return false; + gray->SetGrayPalette(); + work = std::move(gray); + return true; +} + +// アスペクト維持で iw×ih を dst 内に収める矩形(std::min を使わず手計算)。 +static CRect AspectFit(int iw, int ih, const CRect& dst) +{ + if (iw <= 0 || ih <= 0 || dst.Width() <= 0 || dst.Height() <= 0) return dst; + double sx = (double)dst.Width() / iw, sy = (double)dst.Height() / ih; + double s = sx < sy ? sx : sy; + int w = (int)(iw * s + 0.5), h = (int)(ih * s + 0.5); + int x = dst.left + (dst.Width() - w) / 2; + int y = dst.top + (dst.Height() - h) / 2; + return CRect(x, y, x + w, y + h); +} + +// 二値化した一時画像を作る(白=255)。invert で暗い側を白に。複数 roi の内側のみ白く塗る。 +// yfoximg の Threshold を同一バッファへ sub-rect で複数回呼ぶと結果が壊れるため、 +// ここは画素を直接走査して確実にマスクを作る(8bit 前提=呼び出し前に Ensure8 済み)。 +static std::unique_ptr MakeBinary(std::unique_ptr& work, CImgProcessing& /*ip*/, + int thresh, bool invert, int& whiteCount, const std::vector& rois) +{ + int W = work->GetWidth(), H = work->GetHeight(); + auto bin = std::make_unique(); + bin->Create(W, H, 8); + bin->Clear((BYTE)0); + + LPBYTE wb = work->GetBits(); int wp = work->GetPitch(); + LPBYTE bb = bin->GetBits(); int bp = bin->GetPitch(); + whiteCount = 0; + for (const CRect& roi : rois) + { + CRect r = roi; r.NormalizeRect(); + if (r.left < 0) r.left = 0; if (r.top < 0) r.top = 0; + if (r.right > W) r.right = W; if (r.bottom > H) r.bottom = H; + for (int y = r.top; y < r.bottom; y++) + { + const BYTE* ws = wb + (intptr_t)y * wp; + BYTE* bs = bb + (intptr_t)y * bp; + for (int x = r.left; x < r.right; x++) + { + BYTE v = ws[x]; + bool white = invert ? (v <= thresh) : (v >= thresh); + if (white && bs[x] == 0) { bs[x] = 255; whiteCount++; } + } + } + } + bin->SetGrayPalette(); + return bin; +} + +// 複数 roi の外接矩形(union)。 +static CRect UnionRects(const std::vector& rois) +{ + CRect u = rois.empty() ? CRect(0, 0, 0, 0) : rois[0]; + for (const CRect& r : rois) u |= r; + return u; +} + +static bool RunStep(Step& st, std::unique_ptr& work, CImgProcessing& ip, const std::vector& rois) +{ + st.result = StepResult(); + st.result.ran = true; + + const int W = work->GetWidth(), H = work->GetHeight(); + + switch (st.kind) + { + case StepKind::Grayscale: + { + if (!Ensure8(work, ip)) { st.result.ok = false; st.result.text = L"白黒化に失敗"; return false; } + st.result.text = L"白黒に変換しました"; + return true; + } + case StepKind::Median: + { + if (!Ensure8(work, ip)) { st.result.ok = false; st.result.text = L"白黒化に失敗"; return false; } + auto dst = std::make_unique(); + dst->Create(work->GetWidth(), work->GetHeight(), 8); + ip.Median(dst.get(), work.get()); + dst->SetGrayPalette(); + work = std::move(dst); + st.result.text = L"ノイズをならしました"; + return true; + } + case StepKind::Binarize: + { + if (!Ensure8(work, ip)) { st.result.ok = false; st.result.text = L"白黒化に失敗"; return false; } + int lo = ClampI((int)st.Param(L"lo", 128), 0, 255); + int hi = ClampI((int)st.Param(L"hi", 255), 0, 255); + bool inBand = (int)st.Param(L"invert", 0) == 0; + auto dst = std::make_unique(); + dst->Create(W, H, 8); + int cnt = ip.Threshold(dst.get(), work.get(), CRect(0, 0, W, H), lo, hi, inBand); + dst->SetGrayPalette(); + work = std::move(dst); + st.result.measured = cnt; + st.result.text.Format(L"明るさ %d〜%d を白に(白=%d px)", lo, hi, cnt); + return true; + } + case StepKind::Otsu: + { + if (!Ensure8(work, ip)) { st.result.ok = false; st.result.text = L"白黒化に失敗"; return false; } + bool inv = (int)st.Param(L"invert", 0) != 0; + auto dst = std::make_unique(); + dst->Create(W, H, 8); + ip.Threshold_Otsu(dst.get(), work.get(), CRect(0, 0, W, H), inv); + dst->SetGrayPalette(); + work = std::move(dst); + st.result.text = L"自動しきい値で白黒に分けました"; + return true; + } + case StepKind::Gamma: + { + if (!Ensure8(work, ip)) { st.result.ok = false; st.result.text = L"白黒化に失敗"; return false; } + double g = st.Param(L"gamma", 1.0); + if (g < 0.05) g = 0.05; + auto dst = std::make_unique(); + dst->Create(work->GetWidth(), work->GetHeight(), 8); + ip.CreateGammaLUT(g); + ip.Gamma(dst.get(), work.get()); + ip.DeleteGammaLUT(); + dst->SetGrayPalette(); + work = std::move(dst); + st.result.text.Format(L"明るさ調整 %.2f", g); + return true; + } + case StepKind::Contrast: + { + if (!Ensure8(work, ip)) { st.result.ok = false; st.result.text = L"白黒化に失敗"; return false; } + int inMin = ClampI((int)st.Param(L"inMin", 0), 0, 255); + int inMax = ClampI((int)st.Param(L"inMax", 255), 0, 255); + int outMin = ClampI((int)st.Param(L"outMin", 0), 0, 255); + int outMax = ClampI((int)st.Param(L"outMax", 255), 0, 255); + auto dst = std::make_unique(); + dst->Create(work->GetWidth(), work->GetHeight(), 8); + ip.Contrast(dst.get(), work.get(), inMin, inMax, outMin, outMax); + dst->SetGrayPalette(); + work = std::move(dst); + st.result.text = L"コントラストを強めました"; + return true; + } + case StepKind::InspectBrightness: + { + if (!Ensure8(work, ip)) { st.result.ok = false; st.result.text = L"白黒化に失敗"; return false; } + // 全 ROI の面積重み付き平均。 + double sumWA = 0, sumA = 0; + for (const CRect& r : rois) { int a = ip.RectAvg(work.get(), r); double ar = (double)r.Width() * r.Height(); sumWA += a * ar; sumA += ar; } + int avg = (int)(sumA > 0 ? (sumWA / sumA) + 0.5 : 0); + double lo = st.Param(L"min", 50), hi = st.Param(L"max", 200); + st.result.measured = avg; + st.result.ok = (avg >= lo && avg <= hi); + st.result.text.Format(L"平均の明るさ=%d(合格 %.0f〜%.0f)", avg, lo, hi); + return true; + } + case StepKind::InspectWhiteRatio: + { + if (!Ensure8(work, ip)) { st.result.ok = false; st.result.text = L"白黒化に失敗"; return false; } + int th = ClampI((int)st.Param(L"thresh", 128), 0, 255); + int white = 0; + auto bin = MakeBinary(work, ip, th, false, white, rois); + double area = 0; for (const CRect& r : rois) area += (double)r.Width() * r.Height(); + double pct = (area > 0) ? (white * 100.0 / area) : 0.0; + double lo = st.Param(L"min", 0), hi = st.Param(L"max", 100); + st.result.measured = pct; + st.result.ok = (pct >= lo && pct <= hi); + st.result.text.Format(L"白い部分=%.1f%%(合格 %.0f〜%.0f%%)", pct, lo, hi); + return true; + } + case StepKind::InspectBlobCount: + { + if (!Ensure8(work, ip)) { st.result.ok = false; st.result.text = L"白黒化に失敗"; return false; } + int th = ClampI((int)st.Param(L"thresh", 128), 0, 255); + bool inv = (int)st.Param(L"invert", 0) != 0; + int white = 0; + auto bin = MakeBinary(work, ip, th, inv, white, rois); + CRect uni = UnionRects(rois); + ip.LabelingInit(bin.get(), 0, uni); + ip.Labeling(bin.get(), uni, CImgProcessing::NORMAL, false); + int n = ip.m_nLabelCount; + double lo = st.Param(L"min", 0), hi = st.Param(L"max", 0); + st.result.measured = n; + st.result.ok = (n >= lo && n <= hi); + st.result.text.Format(L"塊の数=%d 個(合格 %.0f〜%.0f 個)", n, lo, hi); + return true; + } + } + st.result.ok = false; + st.result.text = L"未知のステップ"; + return false; +} + +RunResult InspectEngine::Run(Recipe& recipe) +{ + RunResult rr; + m_shots.assign(recipe.steps.size(), nullptr); + + if (!HasImage()) + { + rr.error = true; + rr.message = L"画像が読み込まれていません"; + return rr; + } + + m_work = std::make_unique(); + m_work->Copy(m_src.get()); + + // 有効 ROI 群(画像全体でクランプ)。処理ステップは寸法を変えないので全ステップ共通。 + const int IW = m_work->GetWidth(), IH = m_work->GetHeight(); + std::vector eff; + for (CRect r : recipe.rois) + { + r.NormalizeRect(); + if (r.left < 0) r.left = 0; + if (r.top < 0) r.top = 0; + if (r.right > IW) r.right = IW; + if (r.bottom > IH) r.bottom = IH; + if (r.Width() >= 2 && r.Height() >= 2) eff.push_back(r); + } + if (eff.empty()) eff.push_back(CRect(0, 0, IW, IH)); // 指定なし=画像全体 + + CImgProcessing ip; + bool allInspectOk = true; + int failIdx = -1; + + for (int i = 0; i < (int)recipe.steps.size(); i++) + { + Step& st = recipe.steps[i]; + bool ok = RunStep(st, m_work, ip, eff); + + // ステップ適用後のスナップショット(サムネイル用)。 + auto shot = std::make_shared(); + shot->Copy(m_work.get()); + m_shots[i] = shot; + + if (!ok) { rr.error = true; if (failIdx < 0) failIdx = i; } + if (IsInspect(st.kind)) + { + rr.hasInspect = true; + if (!st.result.ok) { allInspectOk = false; if (failIdx < 0) failIdx = i; } + } + } + + if (rr.error) + { + rr.pass = false; + rr.message.Format(L"エラー(ステップ#%d)", failIdx + 1); + } + else if (!rr.hasInspect) + { + rr.pass = false; + rr.message = L"検査ステップがありません"; + } + else + { + rr.pass = allInspectOk; + rr.message = rr.pass ? L"合格 PASS" : L"不合格 FAIL"; + } + return rr; +} + +bool InspectEngine::HasShot(int i) const +{ + return i >= 0 && i < (int)m_shots.size() && m_shots[i] && !m_shots[i]->IsNull(); +} + +bool InspectEngine::SaveResultImage(LPCWSTR path, const Recipe& recipe, const RunResult& rr) +{ + // ベース画像 = 最後のスナップショット(無ければ原画像)。 + CDiBitmap* base = nullptr; + for (int i = (int)m_shots.size() - 1; i >= 0; i--) + if (m_shots[i] && !m_shots[i]->IsNull()) { base = m_shots[i].get(); break; } + if (!base) base = m_src.get(); + if (!base || base->IsNull()) return false; + + int W = base->GetWidth(), H = base->GetHeight(); + CDiBitmap out; + if (!out.Create(W, H, 24)) return false; + + HDC hdc = out.GetDC(); + if (!hdc) return false; + { + CDC* pDC = CDC::FromHandle(hdc); + base->Draw(pDC, CRect(0, 0, W, H), false); // 24bit へ最終画像を焼き込む + + pDC->SetBkMode(TRANSPARENT); + + // ROI 枠(黄)+ 番号 + if (!recipe.rois.empty()) + { + CPen yp(PS_SOLID, (H > 400 ? 3 : 2), RGB(255, 210, 0)); + CPen* op = pDC->SelectObject(&yp); + pDC->SelectStockObject(NULL_BRUSH); + pDC->SetTextColor(RGB(255, 210, 0)); + for (size_t i = 0; i < recipe.rois.size(); i++) + { + pDC->Rectangle(recipe.rois[i]); + CString no; no.Format(L"%d", (int)i + 1); + pDC->TextOut(recipe.rois[i].left + 2, recipe.rois[i].top + 1, no); + } + pDC->SelectObject(op); + } + + // PASS/FAIL バナー(左上) + int fh = H / 16; if (fh < 16) fh = 16; if (fh > 40) fh = 40; + LOGFONT lf = {}; lf.lfHeight = -fh; lf.lfWeight = FW_BOLD; + wcscpy_s(lf.lfFaceName, L"Yu Gothic UI"); + CFont big; big.CreateFontIndirect(&lf); + CFont* of = pDC->SelectObject(&big); + + CString banner = rr.error ? L" ERROR " : (rr.hasInspect ? (rr.pass ? L" PASS " : L" FAIL ") : L" (検査なし) "); + COLORREF bc = rr.error ? RGB(220, 60, 60) : (rr.hasInspect ? (rr.pass ? RGB(40, 170, 80) : RGB(220, 60, 60)) : RGB(90, 100, 110)); + CSize sz = pDC->GetTextExtent(banner); + CRect br(6, 6, 6 + sz.cx + 12, 6 + sz.cy + 8); + pDC->FillSolidRect(br, bc); + pDC->SetTextColor(RGB(255, 255, 255)); + pDC->DrawText(banner, br, DT_CENTER | DT_VCENTER | DT_SINGLELINE); + + // 検査要約(各検査ステップ) + LOGFONT lf2 = {}; lf2.lfHeight = -(fh * 3 / 5 < 12 ? 12 : fh * 3 / 5); + wcscpy_s(lf2.lfFaceName, L"Yu Gothic UI"); + CFont fSmall; fSmall.CreateFontIndirect(&lf2); + pDC->SelectObject(&fSmall); + int y = br.bottom + 6; + for (const Step& st : recipe.steps) + { + if (!IsInspect(st.kind)) continue; + CString line = StepName(st.kind) + L": " + st.result.text + (st.result.ok ? L" [OK]" : L" [NG]"); + COLORREF tc = st.result.ok ? RGB(30, 160, 70) : RGB(210, 50, 50); + // 縁取り(白背景の代わりに黒縁) + pDC->SetTextColor(RGB(0, 0, 0)); + for (int dx = -1; dx <= 1; dx++) for (int dy = -1; dy <= 1; dy++) + pDC->TextOut(8 + dx, y + dy, line); + pDC->SetTextColor(tc); + pDC->TextOut(8, y, line); + y += (fh * 3 / 5) + 6; + if (y > H - 20) break; + } + pDC->SelectObject(of); + } + out.ReleaseDC(); + return out.Save(CString(path)); +} + +CRect InspectEngine::DrawSource(CDC* pDC, const CRect& rc) +{ + if (!m_src || m_src->IsNull()) return CRect(0, 0, 0, 0); + CRect fit = AspectFit(m_src->GetWidth(), m_src->GetHeight(), rc); + m_src->Draw(pDC, fit, false); // アスペクト維持済みの矩形へ正確に描画 + return fit; +} + +CRect InspectEngine::DrawShot(CDC* pDC, const CRect& rc, int i) +{ + if (!HasShot(i)) return CRect(0, 0, 0, 0); + CRect fit = AspectFit(m_shots[i]->GetWidth(), m_shots[i]->GetHeight(), rc); + m_shots[i]->Draw(pDC, fit, false); + return fit; +} + +} // namespace core diff --git a/core/InspectEngine.h b/core/InspectEngine.h new file mode 100644 index 0000000..2a5df5e --- /dev/null +++ b/core/InspectEngine.h @@ -0,0 +1,60 @@ +#pragma once +// +// InspectEngine.h - レシピを yfoximg で実行する検査エンジン。 +// ここが唯一 yfoximg(CImgProcessing / CDiBitmap)に依存する層。 +// ImgProcessing.h は .cpp 側だけで include し、UI への漏れを防ぐ(前方宣言でラップ)。 +// +#include "Recipe.h" +#include +#include + +class CDiBitmap; // 前方宣言(ヘッダに yfoximg を持ち込まない) + +namespace core { + +// 検査全体の結果。 +struct RunResult +{ + bool hasInspect = false; // 検査ステップが1つ以上あった + bool pass = false; // 総合 PASS/FAIL + bool error = false; // 実行時エラーあり + CString message; // サマリ文 +}; + +// レシピ実行器。作業画像(CDiBitmap)と、各ステップ適用後のスナップショットを保持。 +class InspectEngine +{ +public: + InspectEngine(); + ~InspectEngine(); + + bool LoadImage(LPCWSTR path); + bool HasImage() const; + + // レシピを全ステップ実行。各 Step::result を更新し、ステップごとの + // スナップショット画像(サムネイル描画用)を内部に保持する。 + RunResult Run(Recipe& recipe); + + // 判定結果を注釈画像(最終画像+ROI枠+PASS/FAIL+検査要約)として保存。 + bool SaveResultImage(LPCWSTR path, const Recipe& recipe, const RunResult& rr); + + // 原画像を DC にアスペクト維持で描画。実際に描いた矩形(オーバーレイ用)を返す。 + CRect DrawSource(CDC* pDC, const CRect& rc); + // ステップ i 適用後のスナップショットを描画。実際に描いた矩形を返す(無ければ空)。 + CRect DrawShot(CDC* pDC, const CRect& rc, int stepIndex); + bool HasShot(int stepIndex) const; + + int SrcWidth() const; + int SrcHeight() const; + int SrcBpp() const; + int WorkWidth() const; + int WorkHeight() const; + int WorkBpp() const; + +private: + std::unique_ptr m_src; // 読み込んだ原画像 + std::unique_ptr m_work; // 作業画像(処理を積み上げる) + std::vector> m_shots; // steps[i] 適用後の画像 +}; + +} // namespace core diff --git a/core/Recipe.h b/core/Recipe.h new file mode 100644 index 0000000..ee97e37 --- /dev/null +++ b/core/Recipe.h @@ -0,0 +1,81 @@ +#pragma once +// +// Recipe.h - ノーコード「検査レシピ」のデータモデル(UI/画像ライブラリ非依存)。 +// +// パラダイム: ブロックをワイヤで繋ぐノードグラフではなく、 +// 「上から下へ順に実行するステップの縦リスト」= 料理のレシピ / 検査チェックリスト。 +// ステップは2種: 処理(画像を加工) と 検査(値を測って範囲判定=PASS/FAIL に寄与)。 +// +#include +#include // CRect +#include + +namespace core { + +// ステップ種別。process=画像加工 / inspect=測定+判定。 +enum class StepKind +{ + // --- 処理(画像を加工する) --- + Grayscale, // カラー→グレー(24bit→8bit) + Median, // 3x3 メディアン(ノイズ除去) + Binarize, // 二値化(帯 [L,H]) + Otsu, // 大津の二値化(自動しきい値) + Gamma, // ガンマ補正 + Contrast, // コントラスト伸長 + + // --- 検査(測定して範囲で合否) --- + InspectBrightness, // 平均輝度が [min,max] に入れば OK + InspectWhiteRatio, // 二値化後の白画素率(%) が [min,max] に入れば OK + InspectBlobCount, // 二値化+ラベリングで「塊(キズ/穴)の数」が [min,max] に入れば OK +}; + +// 1つのパラメータ(数値)。UI はこの記述を見て入力欄を作る。 +struct StepParam +{ + CString key; // 内部キー + CString label; // 画面表示名 + double value; // 現在値 + double lo; // 入力下限(UIヒント) + double hi; // 入力上限(UIヒント) +}; + +// 実行後の結果(検査ステップのみ意味を持つ)。 +struct StepResult +{ + bool ran = false; // 実行された + bool ok = true; // 検査OK(処理ステップは常に true) + double measured = 0.0; // 測定値(検査ステップ) + CString text; // 表示用の説明(測定値/エラー等) +}; + +// レシピの1ステップ。 +struct Step +{ + StepKind kind; + std::vector params; + StepResult result; + + double Param(LPCWSTR key, double def = 0.0) const + { + for (const auto& p : params) + if (p.key == key) return p.value; + return def; + } + void SetParam(LPCWSTR key, double v) + { + for (auto& p : params) + if (p.key == key) { p.value = v; return; } + } +}; + +// 検査レシピ = ステップの並び + 検査範囲(ROI)。 +struct Recipe +{ + std::vector steps; + + // 検査範囲(画像ピクセル座標)。空のときは画像全体を検査。複数指定すると各範囲を合算。 + // 本アプリの処理ステップは寸法を変えないため、ROI は全ステップで同座標系で有効。 + std::vector rois; +}; + +} // namespace core diff --git a/core/RecipeStore.cpp b/core/RecipeStore.cpp new file mode 100644 index 0000000..26c67ac --- /dev/null +++ b/core/RecipeStore.cpp @@ -0,0 +1,134 @@ +// +// RecipeStore.cpp - レシピの保存/読込。行ベースの UTF-8 テキスト。 +// +// ImgTestSystem Recipe v1 +// ROI +// STEP +// PARAM +// ... +// +#include "stdafx.h" +#include "RecipeStore.h" +#include "StepCatalog.h" + +namespace core { + +namespace { + +void WriteUtf8(CFile& f, const CString& s) +{ + int n = WideCharToMultiByte(CP_UTF8, 0, s, s.GetLength(), NULL, 0, NULL, NULL); + if (n <= 0) return; + CStringA a; + WideCharToMultiByte(CP_UTF8, 0, s, s.GetLength(), a.GetBuffer(n), n, NULL, NULL); + a.ReleaseBuffer(n); + f.Write((LPCSTR)a, a.GetLength()); +} + +CString ReadAllUtf8(LPCWSTR path) +{ + CFile f; + if (!f.Open(path, CFile::modeRead | CFile::shareDenyWrite)) return L""; + ULONGLONG len = f.GetLength(); + if (len == 0 || len > (50 * 1024 * 1024)) return L""; + CStringA a; + UINT got = f.Read(a.GetBuffer((int)len), (UINT)len); + a.ReleaseBuffer(got); + // BOM 除去 + if (a.GetLength() >= 3 && (BYTE)a[0] == 0xEF && (BYTE)a[1] == 0xBB && (BYTE)a[2] == 0xBF) + a = a.Mid(3); + int wn = MultiByteToWideChar(CP_UTF8, 0, a, a.GetLength(), NULL, 0); + CString w; + MultiByteToWideChar(CP_UTF8, 0, a, a.GetLength(), w.GetBuffer(wn), wn); + w.ReleaseBuffer(wn); + return w; +} + +} // namespace + +bool SaveRecipe(LPCWSTR path, const Recipe& r) +{ + CFile f; + if (!f.Open(path, CFile::modeCreate | CFile::modeWrite)) return false; + // UTF-8 BOM + const unsigned char bom[3] = { 0xEF, 0xBB, 0xBF }; + f.Write(bom, 3); + + WriteUtf8(f, L"ImgTestSystem Recipe v1\r\n"); + for (const CRect& roi : r.rois) + { + CString line; + line.Format(L"ROI %d %d %d %d\r\n", (int)roi.left, (int)roi.top, (int)roi.right, (int)roi.bottom); + WriteUtf8(f, line); + } + + for (const Step& s : r.steps) + { + WriteUtf8(f, L"STEP " + StepKindId(s.kind) + L"\r\n"); + for (const StepParam& p : s.params) + { + CString pl; + pl.Format(L"PARAM %s %.6g\r\n", (LPCWSTR)p.key, p.value); + WriteUtf8(f, pl); + } + } + f.Close(); + return true; +} + +bool LoadRecipe(LPCWSTR path, Recipe& out) +{ + CString text = ReadAllUtf8(path); + if (text.IsEmpty()) return false; + + Recipe r; + bool sawHeader = false; + Step* cur = nullptr; + + int pos = 0; + while (pos < text.GetLength()) + { + int nl = text.Find(L'\n', pos); + CString line = (nl < 0) ? text.Mid(pos) : text.Mid(pos, nl - pos); + pos = (nl < 0) ? text.GetLength() : nl + 1; + line.TrimRight(L"\r"); + line.TrimLeft(); + if (line.IsEmpty()) continue; + + if (line.Find(L"ImgTestSystem Recipe") == 0) { sawHeader = true; continue; } + + if (line.Left(4) == L"ROI ") + { + // 新形式: ROI l t r b(4値) / 旧形式: ROI enabled l t r b(5値) + int a = 0, bb = 0, c = 0, d = 0, e = 0; + int got = swscanf_s((LPCWSTR)line, L"ROI %d %d %d %d %d", &a, &bb, &c, &d, &e); + if (got == 5) { if (a != 0) r.rois.push_back(CRect(bb, c, d, e)); } + else if (got == 4) { r.rois.push_back(CRect(a, bb, c, d)); } + continue; + } + if (line.Left(5) == L"STEP ") + { + CString id = line.Mid(5); id.Trim(); + StepKind k; + if (StepKindFromId(id, k)) { r.steps.push_back(MakeStep(k)); cur = &r.steps.back(); } + else cur = nullptr; + continue; + } + if (line.Left(6) == L"PARAM " && cur) + { + CString rest = line.Mid(6); rest.Trim(); + int sp = rest.Find(L' '); + if (sp <= 0) continue; + CString key = rest.Left(sp); + double val = _wtof(rest.Mid(sp + 1)); + cur->SetParam(key, val); // 既定パラメータのうち一致キーのみ上書き + continue; + } + } + + if (r.steps.empty() && !sawHeader) return false; + out = r; + return true; +} + +} // namespace core diff --git a/core/RecipeStore.h b/core/RecipeStore.h new file mode 100644 index 0000000..33a5315 --- /dev/null +++ b/core/RecipeStore.h @@ -0,0 +1,10 @@ +#pragma once +// +// RecipeStore.h - レシピの保存/読込(人が読める UTF-8 テキスト = *.itr)。 +// +#include "Recipe.h" + +namespace core { + bool SaveRecipe(LPCWSTR path, const Recipe& r); + bool LoadRecipe(LPCWSTR path, Recipe& r); +} diff --git a/core/StepCatalog.h b/core/StepCatalog.h new file mode 100644 index 0000000..9ff2419 --- /dev/null +++ b/core/StepCatalog.h @@ -0,0 +1,168 @@ +#pragma once +// +// StepCatalog.h - ステップ種別のメタデータ(表示名/アイコン/平易な説明/種類/既定パラメータ)。 +// 新しいステップ種別を足すときは Recipe.h の StepKind と、この各関数、 +// および InspectEngine::RunStep に1ケース足すだけ。 +// +#include "Recipe.h" + +namespace core { + +// 種別 → 画面表示名(平易な日本語)。 +inline CString StepName(StepKind k) +{ + switch (k) + { + case StepKind::Grayscale: return L"白黒にする"; + case StepKind::Median: return L"ノイズを消す"; + case StepKind::Binarize: return L"明るさで白黒に分ける"; + case StepKind::Otsu: return L"自動で白黒に分ける"; + case StepKind::Gamma: return L"明るさをそろえる"; + case StepKind::Contrast: return L"コントラストを強める"; + case StepKind::InspectBrightness: return L"検査:全体の明るさ"; + case StepKind::InspectWhiteRatio: return L"検査:白い部分の割合"; + case StepKind::InspectBlobCount: return L"検査:塊(キズ/穴)の数"; + } + return L"?"; +} + +// 種別 → アイコン(絵文字)。 +inline CString StepIcon(StepKind k) +{ + switch (k) + { + case StepKind::Grayscale: return L"⬜"; + case StepKind::Median: return L"🧹"; + case StepKind::Binarize: return L"◐"; + case StepKind::Otsu: return L"🎚"; + case StepKind::Gamma: return L"💡"; + case StepKind::Contrast: return L"🌗"; + case StepKind::InspectBrightness: return L"🔎"; + case StepKind::InspectWhiteRatio: return L"🔎"; + case StepKind::InspectBlobCount: return L"🔎"; + } + return L"•"; +} + +// 種別 → 1行の平易な説明(このステップは何をするか)。 +inline CString StepHint(StepKind k) +{ + switch (k) + { + case StepKind::Grayscale: return L"カラー画像を白黒(グレー)にします。検査の前準備。"; + case StepKind::Median: return L"細かいザラつき(ノイズ)をならして消します。"; + case StepKind::Binarize: return L"指定した明るさの範囲だけを白にし、他を黒にします。"; + case StepKind::Otsu: return L"しきい値を自動で決めて白黒に分けます。"; + case StepKind::Gamma: return L"暗い所/明るい所の見え方を調整します。"; + case StepKind::Contrast: return L"明暗の差をはっきりさせます。"; + case StepKind::InspectBrightness: return L"画像全体の平均の明るさが範囲内なら合格。"; + case StepKind::InspectWhiteRatio: return L"白い部分の割合(%)が範囲内なら合格。"; + case StepKind::InspectBlobCount: return L"白い塊(キズや穴)の数が範囲内なら合格。"; + } + return L""; +} + +// 種別 → 保存用の安定した文字列ID(enum値は将来変わり得るため文字列で永続化)。 +inline CString StepKindId(StepKind k) +{ + switch (k) + { + case StepKind::Grayscale: return L"grayscale"; + case StepKind::Median: return L"median"; + case StepKind::Binarize: return L"binarize"; + case StepKind::Otsu: return L"otsu"; + case StepKind::Gamma: return L"gamma"; + case StepKind::Contrast: return L"contrast"; + case StepKind::InspectBrightness: return L"inspect_brightness"; + case StepKind::InspectWhiteRatio: return L"inspect_whiteratio"; + case StepKind::InspectBlobCount: return L"inspect_blobcount"; + } + return L"?"; +} + +// 文字列ID → 種別。見つかれば true。 +inline bool StepKindFromId(const CString& id, StepKind& out) +{ + static const StepKind all[] = { + StepKind::Grayscale, StepKind::Median, StepKind::Binarize, StepKind::Otsu, + StepKind::Gamma, StepKind::Contrast, StepKind::InspectBrightness, + StepKind::InspectWhiteRatio, StepKind::InspectBlobCount, + }; + for (StepKind k : all) + if (StepKindId(k) == id) { out = k; return true; } + return false; +} + +// 検査ステップか(合否に寄与するか)。 +inline bool IsInspect(StepKind k) +{ + return k == StepKind::InspectBrightness + || k == StepKind::InspectWhiteRatio + || k == StepKind::InspectBlobCount; +} + +// 種別ごとの既定パラメータを持つ Step を生成。 +inline Step MakeStep(StepKind k) +{ + Step s; + s.kind = k; + switch (k) + { + case StepKind::Grayscale: + case StepKind::Median: + break; + case StepKind::Otsu: + s.params.push_back({ L"invert", L"白黒を反転", 0, 0, 1 }); + break; + case StepKind::Binarize: + s.params.push_back({ L"lo", L"この明るさ以上", 128, 0, 255 }); + s.params.push_back({ L"hi", L"この明るさ以下", 255, 0, 255 }); + s.params.push_back({ L"invert", L"白黒を反転", 0, 0, 1 }); + break; + case StepKind::Gamma: + s.params.push_back({ L"gamma", L"明るさ調整(1=そのまま)", 1.0, 0.2, 3.0 }); + break; + case StepKind::Contrast: + s.params.push_back({ L"inMin", L"暗side基準", 0, 0, 255 }); + s.params.push_back({ L"inMax", L"明side基準", 255, 0, 255 }); + s.params.push_back({ L"outMin", L"出力min", 0, 0, 255 }); + s.params.push_back({ L"outMax", L"出力max", 255, 0, 255 }); + break; + case StepKind::InspectBrightness: + s.params.push_back({ L"min", L"合格の最小(暗い側)", 50, 0, 255 }); + s.params.push_back({ L"max", L"合格の最大(明るい側)", 200, 0, 255 }); + break; + case StepKind::InspectWhiteRatio: + s.params.push_back({ L"thresh", L"白とみなす明るさ", 128, 0, 255 }); + s.params.push_back({ L"min", L"合格の最小(%)", 0, 0, 100 }); + s.params.push_back({ L"max", L"合格の最大(%)", 100, 0, 100 }); + break; + case StepKind::InspectBlobCount: + s.params.push_back({ L"thresh", L"白とみなす明るさ", 128, 0, 255 }); + s.params.push_back({ L"invert", L"暗い塊を数える", 0, 0, 1 }); + s.params.push_back({ L"min", L"合格の最小(個)", 0, 0, 100 }); + s.params.push_back({ L"max", L"合格の最大(個)", 0, 0, 100 }); + break; + } + return s; +} + +// パレット(追加できる種別の一覧・並び順)。 +inline const StepKind* AllStepKinds(int& n) +{ + static const StepKind kinds[] = { + StepKind::Grayscale, + StepKind::Median, + StepKind::Binarize, + StepKind::Otsu, + StepKind::Gamma, + StepKind::Contrast, + StepKind::InspectBrightness, + StepKind::InspectWhiteRatio, + StepKind::InspectBlobCount, + }; + n = (int)(sizeof(kinds) / sizeof(kinds[0])); + return kinds; +} + +} // namespace core diff --git a/core/Templates.h b/core/Templates.h new file mode 100644 index 0000000..bf27b14 --- /dev/null +++ b/core/Templates.h @@ -0,0 +1,84 @@ +#pragma once +// +// Templates.h - 「目的から選ぶ」ための完成済みレシピ。 +// ユーザーは目的を選ぶだけ。あとは右パネルのスライダーで微調整できる。 +// +#include "Recipe.h" +#include "StepCatalog.h" +#include + +namespace core { + +struct RecipeTemplate +{ + CString name; // 目的名 + CString desc; // 説明 + Recipe recipe; // 中身 +}; + +inline Step Mk(StepKind k) { return MakeStep(k); } + +inline std::vector AllTemplates() +{ + std::vector v; + + // 1) キズ・汚れを見つける + { + RecipeTemplate t; + t.name = L"キズ・汚れを見つける"; + t.desc = L"白黒化→ノイズ除去→自動二値化し、キズ(白い塊)の数が0個なら合格。"; + t.recipe.steps.push_back(Mk(StepKind::Grayscale)); + t.recipe.steps.push_back(Mk(StepKind::Median)); + t.recipe.steps.push_back(Mk(StepKind::Otsu)); + { Step s = Mk(StepKind::InspectBlobCount); + s.SetParam(L"thresh", 128); s.SetParam(L"invert", 1); // 暗い欠陥を数える + s.SetParam(L"min", 0); s.SetParam(L"max", 0); + t.recipe.steps.push_back(s); } + v.push_back(t); + } + + // 2) 欠け・穴を数える + { + RecipeTemplate t; + t.name = L"欠け・穴を数える"; + t.desc = L"白黒化→自動二値化し、穴(塊)の数が想定範囲(既定1〜4個)なら合格。"; + t.recipe.steps.push_back(Mk(StepKind::Grayscale)); + t.recipe.steps.push_back(Mk(StepKind::Otsu)); + { Step s = Mk(StepKind::InspectBlobCount); + s.SetParam(L"thresh", 128); s.SetParam(L"invert", 1); // 暗い穴を数える + s.SetParam(L"min", 1); s.SetParam(L"max", 4); + t.recipe.steps.push_back(s); } + v.push_back(t); + } + + // 3) 明るさ(ムラ)を確認する + { + RecipeTemplate t; + t.name = L"明るさを確認する"; + t.desc = L"白黒化し、全体の平均の明るさが範囲(既定80〜180)なら合格。"; + t.recipe.steps.push_back(Mk(StepKind::Grayscale)); + { Step s = Mk(StepKind::InspectBrightness); + s.SetParam(L"min", 80); s.SetParam(L"max", 180); + t.recipe.steps.push_back(s); } + v.push_back(t); + } + + // 4) 文字・印字の有無を見る + { + RecipeTemplate t; + t.name = L"文字・印字の有無を見る"; + t.desc = L"白黒化→二値化し、インク(白い部分)の割合が一定以上あれば合格=印字あり。"; + t.recipe.steps.push_back(Mk(StepKind::Grayscale)); + { Step s = Mk(StepKind::Binarize); + s.SetParam(L"lo", 128); s.SetParam(L"hi", 255); s.SetParam(L"invert", 1); + t.recipe.steps.push_back(s); } + { Step s = Mk(StepKind::InspectWhiteRatio); + s.SetParam(L"thresh", 128); s.SetParam(L"min", 2); s.SetParam(L"max", 100); + t.recipe.steps.push_back(s); } + v.push_back(t); + } + + return v; +} + +} // namespace core diff --git a/stdafx.cpp b/stdafx.cpp new file mode 100644 index 0000000..fd4f341 --- /dev/null +++ b/stdafx.cpp @@ -0,0 +1 @@ +#include "stdafx.h" diff --git a/stdafx.h b/stdafx.h new file mode 100644 index 0000000..54c8e2a --- /dev/null +++ b/stdafx.h @@ -0,0 +1,28 @@ +#pragma once +// +// stdafx.h - プリコンパイル済みヘッダ。 +// yfoximg(ImgProcessing.h)はここには入れない。裸の min/max マクロ前提のため、 +// 必要な TU(InspectEngine.cpp / SelfTest.cpp)だけで include する。 +// +#include "targetver.h" + +#ifndef VC_EXTRALEAN +#define VC_EXTRALEAN +#endif + +#include // MFC コア / 標準 +#include // MFC 拡張 +#include // CSliderCtrl 等 コモンコントロール +#include // CFileDialog +#include // CTaskDialog(ウィザード/テンプレート選択) + +// CTaskDialog / 新しい外観のため Common Controls v6 に依存させる。 +#pragma comment(linker, "\"/manifestdependency:type='win32' \ +name='Microsoft.Windows.Common-Controls' version='6.0.0.0' \ +processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'\"") + +#include +#include + +#include // GDI+(CImage/CImageEx が使用) +#pragma comment(lib, "gdiplus.lib") diff --git a/targetver.h b/targetver.h new file mode 100644 index 0000000..941e089 --- /dev/null +++ b/targetver.h @@ -0,0 +1,2 @@ +#pragma once +#include