diff --git a/README.md b/README.md index 5bed24f3..3773bdc6 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ for (let i = 0; i < n; i++) { | smoothing | (Linear weighted / Triangular / Cumulative) Moving average, Exponential average, Moving median, KZ filter, Savitzky Golay filter, Hampel filter, Kalman filter, Particle filter, Lowpass filter, Bessel filter, Butterworth filter, Chebyshev filter, Elliptic filter | | timeseries prediction | Holt winters, AR, ARMA, SDAR, VAR, Kalman filter, MLP, RNN | | change point detection | Cumulative sum, k-nearest neighbor, LOF, COF, SST, KLIEP, LSIF, uLSIF, LSDD, PELT, HMM, Markov switching | -| segmentation | P-Tile, Automatic thresholding, Balanced histogram thresholding, Otsu's method, Sezan, Adaptive thresholding, Bernsen, Niblack, Sauvola, Phansalkar, Split and merge, Statistical Region Merging, Mean shift | +| segmentation | P-Tile, Automatic thresholding, Balanced histogram thresholding, Otsu's method, Kittler Illingworth thresholding, Sezan, Adaptive thresholding, Bernsen, Niblack, Sauvola, Phansalkar, Split and merge, Statistical Region Merging, Mean shift | | denoising | NL-means, Hopfield network, RBM, GBRBM | | edge detection | Roberts cross, Sobel, Prewitt, Laplacian, LoG, Canny, Snakes | | word embedding | Word2Vec | diff --git a/js/model_selector.js b/js/model_selector.js index 2bbdd72a..430a860d 100644 --- a/js/model_selector.js +++ b/js/model_selector.js @@ -548,6 +548,7 @@ const AIMethods = [ { value: 'automatic_thresholding', title: 'Automatic Thresholding' }, { value: 'balanced_histogram', title: 'Balanced histogram thresholding' }, { value: 'otsu', title: 'Otsu' }, + { value: 'kittler_illingworth', title: 'Kittler-Illingworth' }, { value: 'sezan', title: 'Sezan' }, { value: 'adaptive_thresholding', title: 'Adaptive Thresholding' }, { value: 'bernsen', title: 'Bernsen' }, diff --git a/js/view/kittler_illingworth.js b/js/view/kittler_illingworth.js new file mode 100644 index 00000000..efa7fce1 --- /dev/null +++ b/js/view/kittler_illingworth.js @@ -0,0 +1,26 @@ +import KittlerIllingworthThresholding from '../../lib/model/kittler_illingworth.js' +import Controller from '../controller.js' +import { specialCategory } from '../utils.js' + +export default function (platform) { + platform.setting.ml.usage = 'Click "Fit" button.' + platform.setting.ml.reference = { + author: 'J. Kittler, J. Illingworth', + title: 'Minimum error thresholding', + year: 1985, + } + platform.colorSpace = 'gray' + const controller = new Controller(platform) + const fitModel = () => { + const orgStep = platform._step + platform._step = 1 + const model = new KittlerIllingworthThresholding() + const y = model.predict(platform.trainInput.flat(2)) + threshold.value = model._t + platform.trainResult = y.map(v => specialCategory.density(1 - v)) + platform._step = orgStep + } + + controller.input.button('Fit').on('click', fitModel) + const threshold = controller.text({ label: ' Estimated threshold ' }) +} diff --git a/lib/model/kittler_illingworth.js b/lib/model/kittler_illingworth.js new file mode 100644 index 00000000..287bb1cb --- /dev/null +++ b/lib/model/kittler_illingworth.js @@ -0,0 +1,62 @@ +/** + * Minimum error thresholding / Kittler-Illingworth Thresholding + */ +export default class KittlerIllingworthThresholding { + // Minimum error thresholding + // https://jp.mathworks.com/matlabcentral/fileexchange/45685-kittler-illingworth-thresholding + // http://www.thothchildren.com/chapter/5bc4b10451d930518902af3b + constructor() {} + + /** + * Returns thresholded values. + * @param {number[]} x Training data + * @returns {(0 | 1)[]} Predicted values + */ + predict(x) { + this._cand = [...new Set(x)] + this._cand.sort((a, b) => a - b) + const n = this._cand.length + + let best_j = Infinity + let best_t = 0 + for (let t = 2; t < n - 1; t++) { + let p0 = 0 + let p1 = 0 + let m0 = 0 + let m1 = 0 + for (let i = 0; i < n; i++) { + if (x[i] < this._cand[t]) { + p0++ + m0 += x[i] + } else { + p1++ + m1 += x[i] + } + } + const r0 = p0 / n + const r1 = p1 / n + m0 /= p0 + m1 /= p1 + + let s1 = 0 + let s2 = 0 + for (let i = 0; i < n; i++) { + if (x[i] < this._cand[t]) { + s1 += (x[i] - m0) ** 2 + } else { + s2 += (x[i] - m1) ** 2 + } + } + s1 /= p0 + s2 /= p1 + + const j = r0 * Math.log(Math.sqrt(s1) / r0) + r1 * Math.log(Math.sqrt(s2) / r1) + if (j < best_j) { + best_j = j + best_t = t + } + } + this._t = this._cand[best_t] + return x.map(v => (v < this._t ? 0 : 1)) + } +} diff --git a/tests/gui/view/kittler_illingworth.test.js b/tests/gui/view/kittler_illingworth.test.js new file mode 100644 index 00000000..55a4e942 --- /dev/null +++ b/tests/gui/view/kittler_illingworth.test.js @@ -0,0 +1,71 @@ +import { getPage } from '../helper/browser' + +describe('segmentation', () => { + /** @type {Awaited>} */ + let page + beforeEach(async () => { + page = await getPage() + + const dataURL = await page.evaluate(() => { + const canvas = document.createElement('canvas') + canvas.width = 100 + canvas.height = 100 + const context = canvas.getContext('2d') + const imdata = context.createImageData(canvas.width, canvas.height) + for (let i = 0, c = 0; i < canvas.height; i++) { + for (let j = 0; j < canvas.width; j++, c += 4) { + imdata.data[c] = Math.floor(Math.random() * 256) + imdata.data[c + 1] = Math.floor(Math.random() * 256) + imdata.data[c + 2] = Math.floor(Math.random() * 256) + imdata.data[c + 3] = Math.random() + } + } + context.putImageData(imdata, 0, 0) + return canvas.toDataURL() + }) + const data = dataURL.replace(/^data:image\/\w+;base64,/, '') + const buf = Buffer.from(data, 'base64') + + const dataSelectBox = page.locator('#ml_selector dl:first-child dd:nth-child(2) select') + await dataSelectBox.selectOption('upload') + + const uploadFileInput = page.locator('#ml_selector #data_menu input[type=file]') + await uploadFileInput.setInputFiles({ + name: 'image_kittler_illingworth.png', + mimeType: 'image/png', + buffer: buf, + }) + + const taskSelectBox = page.locator('#ml_selector dl:first-child dd:nth-child(5) select') + await taskSelectBox.selectOption('SG') + const modelSelectBox = page.locator('#ml_selector .model_selection #mlDisp') + await modelSelectBox.selectOption('kittler_illingworth') + }) + + afterEach(async () => { + await page?.close() + }) + + test('initialize', async () => { + const methodMenu = page.locator('#ml_selector #method_menu') + const buttons = methodMenu.locator('.buttons') + + const threshold = buttons.locator('span:last-child') + await expect(threshold.textContent()).resolves.toBe('') + }) + + test('learn', async () => { + const methodMenu = page.locator('#ml_selector #method_menu') + const buttons = methodMenu.locator('.buttons') + + await expect(page.locator('#image-area canvas').count()).resolves.toBe(1) + const threshold = buttons.locator('span:last-child') + await expect(threshold.textContent()).resolves.toBe('') + + const fitButton = buttons.locator('input[value=Fit]') + await fitButton.dispatchEvent('click') + + await expect(threshold.textContent()).resolves.toMatch(/^[0-9.]+$/) + await expect(page.locator('#image-area canvas').count()).resolves.toBe(2) + }) +}) diff --git a/tests/lib/model/kittler_illingworth.test.js b/tests/lib/model/kittler_illingworth.test.js new file mode 100644 index 00000000..dbadfcd9 --- /dev/null +++ b/tests/lib/model/kittler_illingworth.test.js @@ -0,0 +1,20 @@ +import Matrix from '../../../lib/util/matrix.js' +import KittlerIllingworthThresholding from '../../../lib/model/kittler_illingworth.js' + +import { randIndex } from '../../../lib/evaluate/clustering.js' + +test('clustering', () => { + const model = new KittlerIllingworthThresholding() + const n = 50 + const x = Matrix.concat(Matrix.randn(n, 1, 0, 0.1), Matrix.randn(n, 1, 5, 0.1)).value + + const y = model.predict(x) + expect(y).toHaveLength(x.length) + + const t = [] + for (let i = 0; i < x.length; i++) { + t[i] = Math.floor(i / n) + } + const ri = randIndex(y, t) + expect(ri).toBeGreaterThan(0.9) +})