Files
yoshikazuandClaude Opus 4.8 e4eca6b303 Initial commit: ノーコード画像検査アプリ (MVP + 追加機能一式)
検査レシピ=処理/検査ステップの縦リスト(配線なし)。yfoxlib(yfoximg/yfoxbase)使用。

- ビジュアル・パイプライン(カード+適用後サムネイル+緑/赤 合否)
- テンプレート/対話式ウィザードでレシピ生成(CTaskDialog)
- パラメータのスライダー・ライブ調整
- レシピ保存/読込(*.itr) + 最近使ったファイル(MRU)
- 検査範囲(ROI)を複数マウス指定(ドラッグ追加/右クリック削除)
- カードのドラッグ並べ替え
- 判定結果画像の保存 + 検査ログCSV(項目拡張)
- 処理6種+検査3種(明るさ/白画素率/塊の数)
- /selftest(実行+レシピ往復+結果画像+複数ROI塊カウント検証)、/demo

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 09:26:10 +09:00

436 lines
15 KiB
C++

//
// 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<CDiBitmap>();
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<CDiBitmap> Down32To24(CDiBitmap* src)
{
int W = src->GetWidth(), H = src->GetHeight();
auto dst = std::make_unique<CDiBitmap>();
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<CDiBitmap>& 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<CDiBitmap>();
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<CDiBitmap> MakeBinary(std::unique_ptr<CDiBitmap>& work, CImgProcessing& /*ip*/,
int thresh, bool invert, int& whiteCount, const std::vector<CRect>& rois)
{
int W = work->GetWidth(), H = work->GetHeight();
auto bin = std::make_unique<CDiBitmap>();
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<CRect>& 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<CDiBitmap>& work, CImgProcessing& ip, const std::vector<CRect>& 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<CDiBitmap>();
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<CDiBitmap>();
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<CDiBitmap>();
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<CDiBitmap>();
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<CDiBitmap>();
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<CDiBitmap>();
m_work->Copy(m_src.get());
// 有効 ROI 群(画像全体でクランプ)。処理ステップは寸法を変えないので全ステップ共通。
const int IW = m_work->GetWidth(), IH = m_work->GetHeight();
std::vector<CRect> 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<CDiBitmap>();
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