commit e4eca6b303132dfa0dbce4c759cb28a2c6e92e4d Author: yoshikazu Date: Thu Jul 23 09:26:10 2026 +0900 Initial commit: ノーコード画像検査アプリ (MVP + 追加機能一式) 検査レシピ=処理/検査ステップの縦リスト(配線なし)。yfoxlib(yfoximg/yfoxbase)使用。 - ビジュアル・パイプライン(カード+適用後サムネイル+緑/赤 合否) - テンプレート/対話式ウィザードでレシピ生成(CTaskDialog) - パラメータのスライダー・ライブ調整 - レシピ保存/読込(*.itr) + 最近使ったファイル(MRU) - 検査範囲(ROI)を複数マウス指定(ドラッグ追加/右クリック削除) - カードのドラッグ並べ替え - 判定結果画像の保存 + 検査ログCSV(項目拡張) - 処理6種+検査3種(明るさ/白画素率/塊の数) - /selftest(実行+レシピ往復+結果画像+複数ROI塊カウント検証)、/demo Co-Authored-By: Claude Opus 4.8 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