mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-10-03 07:55:22 -04:00
The four points from the review on #5076 that the first round left open. Persist a ratio as the number that will be read back. zone.php reloads these cookies through validInt(), which strips everything that is not a digit, so 70.5 was stored and returned as 705 and 1e2 as 12, while the live calculation took the same field through parseFloat and used it as typed. The ratio therefore changed meaning on reload. Round and range-check before storing, and write the result into the field so what is shown, what is calculated and what is stored are one number. Clear the tool when the page's Reset is pressed. Reset restores every field from initialValues and redraws the polygon, so the measurement Undo would revert is already gone, but undoSnapshot, lastBox, the highlighting and the rectangle all survived it. The handler is wrapped rather than joined by a listener because ours has to run first: resetChanges calls updateArea, which now re-derives. Re-derive when the zone area changes. The thresholds are stored as percentages of the zone, so moving, adding or removing a vertex changes what they mean in pixels and the saved numbers stop describing the object that was measured. updateArea is the one funnel those edits go through, so wrap it and re-run the derivation from the box still on screen. apply() only reaches updateAllPixelDisplays, so there is no way back into updateArea from there. Draw with pointer events, and give the tool a keyboard-operable equivalent. pointerdown/move/up with pointer capture replaces the mouse-only gesture, which touch and pen could not drive at all, and touch-action is suppressed while armed so the browser does not claim the drag for a scroll. pointercancel ends the drag too, since a pointer taken away by the browser never sends pointerup. For the keyboard there is now an object size in capture pixels beside the ratios: it derives through the same path, draws the same box centred in the frame, and raises the same filter warning. A finished drag writes its size back into those fields, so the two ways in stay in step. Also re-check measurability at pointerdown. The type listener added in the first round covers the select, but applyPreset writes Type directly and fires no change event, so a preset that makes the zone Inactive or Privacy left the tool armed and apply() would re-enable the fields applyZoneType had disabled. tests/js/zone-object-size.test.js covers the two new pure functions: 46 assertions pass. ESLint clean on the module and the test; php -l clean on zone.php, zone.js.php and en_gb.php. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
327 lines
15 KiB
JavaScript
327 lines
15 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('assert');
|
|
const path = require('path');
|
|
const ZM = require(path.join(__dirname,
|
|
'../../web/skins/classic/views/js/zone-object-size.js'));
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
function test(name, fn) {
|
|
try {
|
|
fn();
|
|
console.log(' ok ' + name);
|
|
passed++;
|
|
} catch (e) {
|
|
console.error(' FAIL ' + name);
|
|
console.error(' ' + e.message);
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
// A real case, in capture pixels throughout: a 2560x1920 camera, a zone
|
|
// covering 34.76% of it, and a box drawn round a person at the gate.
|
|
const ZONE_PX = 1708524;
|
|
const BOX_PX = 116 * 235; // 27260
|
|
|
|
// The shipped defaults, as rendered by $rectangle_ratios in zone.php. Mirrored
|
|
// here so the derivations below can be checked against the documentation; the
|
|
// module itself holds no defaults.
|
|
const RATIOS = {fraction: 0.25, filter: 0.7, blob: 0.86};
|
|
|
|
console.log('objectThresholdPixels');
|
|
test('alarm threshold is a quarter of the object', () => {
|
|
// definezone.rst [J]: "a good starting point ... is 25% of the area that an
|
|
// object of interest takes up in the Zone region". A bounding box is mostly
|
|
// not the object - corners of sky and fence - and the pixels that are the
|
|
// object only count when they clear MinPixelThreshold.
|
|
assert.strictEqual(ZM.objectThresholdPixels(BOX_PX, RATIOS).alarm, 6815);
|
|
});
|
|
test('each stage steps down from the one it is drawn from', () => {
|
|
// Filtered is a fraction of alarmed; blob is a fraction of FILTERED, not of
|
|
// alarmed, because a blob is built from pixels that survived the filter.
|
|
const px = ZM.objectThresholdPixels(BOX_PX, RATIOS);
|
|
assert.ok(Math.abs(px.filter - 6815 * 0.7) < 1e-9);
|
|
assert.ok(Math.abs(px.blob - px.filter * 0.86) < 1e-9);
|
|
});
|
|
test('blob can never exceed filter, whatever is typed', () => {
|
|
// Structural rather than clamped: a ratio of at most 1 applied to the
|
|
// filtered count cannot land above it. validateForm rejects the zone
|
|
// outright if MinBlob > MinFilter.
|
|
[0.1, 0.5, 1].forEach((r) => {
|
|
const px = ZM.objectThresholdPixels(BOX_PX, Object.assign({}, RATIOS, {blob: r}));
|
|
assert.ok(px.blob <= px.filter, 'blob ratio ' + r);
|
|
assert.ok(px.filter <= px.alarm, 'filter ratio ' + r);
|
|
});
|
|
});
|
|
test('kept unrounded, so percentages can round exactly once', () => {
|
|
const px = ZM.objectThresholdPixels(BOX_PX, RATIOS);
|
|
assert.ok(Math.abs(px.alarm - BOX_PX * 0.25) < 1e-9);
|
|
assert.ok(Math.abs(px.filter - px.alarm * 0.7) < 1e-9);
|
|
assert.ok(Math.abs(px.blob - px.filter * 0.86) < 1e-9);
|
|
});
|
|
test('honours a chosen fraction of the rectangle', () => {
|
|
// The Alarmed area field, so the 25% rule is a starting point rather than a
|
|
// hardcoded law.
|
|
assert.strictEqual(ZM.objectThresholdPixels(1000, Object.assign({}, RATIOS, {fraction: 0.5})).alarm, 500);
|
|
assert.strictEqual(ZM.objectThresholdPixels(1000, Object.assign({}, RATIOS, {fraction: 0.1})).alarm, 100);
|
|
});
|
|
test('honours chosen filter and blob ratios', () => {
|
|
const px = ZM.objectThresholdPixels(1000, {fraction: 0.5, filter: 0.9, blob: 0.2});
|
|
assert.strictEqual(px.alarm, 500);
|
|
assert.strictEqual(px.filter, 450);
|
|
assert.strictEqual(px.blob, 90, 'blob is 0.2 of FILTERED, not of alarmed');
|
|
});
|
|
test('null on a degenerate box', () => {
|
|
assert.strictEqual(ZM.objectThresholdPixels(0, RATIOS), null);
|
|
assert.strictEqual(ZM.objectThresholdPixels(-5, RATIOS), null);
|
|
});
|
|
|
|
console.log('pixelsToPercent');
|
|
test('converts against zone area, not frame area', () => {
|
|
// The stored percentage is of the ZONE (zm_zone.cpp:1020 multiplies it by
|
|
// polygon.Area()), so the same object needs a bigger percentage in a
|
|
// smaller zone.
|
|
assert.strictEqual(ZM.pixelsToPercent(6815, ZONE_PX), 0.4);
|
|
assert.strictEqual(ZM.pixelsToPercent(6815, ZONE_PX / 2), 0.8);
|
|
});
|
|
test('rounds to 2dp to match the DECIMAL(10,2) column', () => {
|
|
// 6815/1708524*100 = 0.39888...
|
|
const v = ZM.pixelsToPercent(6815, ZONE_PX);
|
|
assert.strictEqual(v, Math.round(v * 100) / 100);
|
|
});
|
|
test('clamps to 100 when the box is larger than the zone', () => {
|
|
assert.strictEqual(ZM.pixelsToPercent(ZONE_PX * 8, ZONE_PX), 100);
|
|
});
|
|
test('floors at 0.01 rather than rounding a tiny box to zero', () => {
|
|
// 0 would disable the threshold entirely - CheckAlarms skips the check when
|
|
// the minimum is 0, turning "small objects only" into "no minimum at all".
|
|
assert.strictEqual(ZM.pixelsToPercent(1, ZONE_PX), 0.01);
|
|
});
|
|
test('null on a degenerate zone or count', () => {
|
|
assert.strictEqual(ZM.pixelsToPercent(6815, 0), null);
|
|
assert.strictEqual(ZM.pixelsToPercent(0, ZONE_PX), null);
|
|
});
|
|
|
|
console.log('zoneSettingsForObject');
|
|
test('reproduces the worked example in definezone.rst [J]', () => {
|
|
// "if a subject moving through the frame takes up approximately 30% of the
|
|
// Zone region, then a good starting minimum Alarmed Area is about 7.5%".
|
|
const s = ZM.zoneSettingsForObject(300000, 1000000, null, RATIOS);
|
|
assert.strictEqual(s.MinAlarmPixels, 7.5);
|
|
});
|
|
test('a person-sized box on a real zone', () => {
|
|
const s = ZM.zoneSettingsForObject(BOX_PX, ZONE_PX, null, RATIOS);
|
|
assert.strictEqual(s.MinAlarmPixels, 0.4);
|
|
assert.strictEqual(s.MinFilterPixels, 0.28);
|
|
assert.strictEqual(s.MinBlobPixels, 0.24);
|
|
assert.strictEqual(s.MinBlobs, 1);
|
|
});
|
|
test('derived values round once, not twice', () => {
|
|
// The point of computing in pixels. With boxPx 1104 in a 100000px zone the
|
|
// raw alarm percentage is 0.276, which rounds to 0.28. Deriving the filter
|
|
// from that rounded 0.28 gives 0.196 -> 0.20, whereas deriving it from the
|
|
// exact 0.276 gives 0.1932 -> 0.19. The second is correct.
|
|
const s = ZM.zoneSettingsForObject(1104, 100000, null, RATIOS);
|
|
assert.strictEqual(s.MinAlarmPixels, 0.28);
|
|
assert.strictEqual(s.MinFilterPixels, 0.19, 'double rounding would give 0.20');
|
|
});
|
|
test('strictly decreasing, so validateForm accepts them', () => {
|
|
// validateForm errors when MinAlarm < MinFilter or MinFilter < MinBlob.
|
|
const s = ZM.zoneSettingsForObject(BOX_PX, ZONE_PX, null, RATIOS);
|
|
assert.ok(s.MinAlarmPixels > s.MinFilterPixels);
|
|
assert.ok(s.MinFilterPixels > s.MinBlobPixels);
|
|
});
|
|
test('ordering still holds once everything hits the 0.01 floor', () => {
|
|
// They collapse to equal rather than inverting. Equal is what validateForm
|
|
// permits; inverted would be rejected.
|
|
const s = ZM.zoneSettingsForObject(4, ZONE_PX, null, RATIOS);
|
|
assert.ok(s.MinAlarmPixels >= s.MinFilterPixels, 'alarm >= filter');
|
|
assert.ok(s.MinFilterPixels >= s.MinBlobPixels, 'filter >= blob');
|
|
});
|
|
test('forces Blobs, the only per-object method', () => {
|
|
// MinAlarmPixels and MinFilterPixels are zone-wide sums, so several small
|
|
// movers can reach them together. MinBlobPixels is tested per blob.
|
|
assert.strictEqual(ZM.zoneSettingsForObject(BOX_PX, ZONE_PX, null, RATIOS).CheckMethod, 'Blobs');
|
|
});
|
|
test('every Max is blank, because each one suppresses detection', () => {
|
|
// MaxAlarmPixels/MaxFilterPixels/MaxBlobs set overload_count, blinding the
|
|
// zone for OverloadFrames frames. MaxBlobPixels deletes the blob outright.
|
|
// A ceiling from one box would reject the same object seen closer.
|
|
const s = ZM.zoneSettingsForObject(BOX_PX, ZONE_PX, null, RATIOS);
|
|
['MaxAlarmPixels', 'MaxFilterPixels', 'MaxBlobPixels', 'MaxBlobs',
|
|
'MaxPixelThreshold'].forEach((f) => assert.strictEqual(s[f], '', f));
|
|
});
|
|
test('nothing can trigger overload, so OverloadFrames is 0', () => {
|
|
assert.strictEqual(ZM.zoneSettingsForObject(BOX_PX, ZONE_PX, null, RATIOS).OverloadFrames, 0);
|
|
});
|
|
test('writes every managed field, so nothing is left half-applied', () => {
|
|
const s = ZM.zoneSettingsForObject(BOX_PX, ZONE_PX, null, RATIOS);
|
|
ZM.MANAGED_FIELDS.forEach((f) => assert.ok(f in s, f + ' missing'));
|
|
});
|
|
test('does not touch ExtendAlarmFrames', () => {
|
|
// applyZoneType disables it on anything but a Preclusive zone, so a write
|
|
// could not submit anyway.
|
|
const s = ZM.zoneSettingsForObject(BOX_PX, ZONE_PX, null, RATIOS);
|
|
assert.ok(!('ExtendAlarmFrames' in s));
|
|
assert.ok(ZM.MANAGED_FIELDS.indexOf('ExtendAlarmFrames') < 0);
|
|
});
|
|
test('passes the chosen ratios through to the thresholds', () => {
|
|
// Doubling the fraction doubles all three, since filter and blob are derived
|
|
// from the alarm figure.
|
|
const base = ZM.zoneSettingsForObject(BOX_PX, ZONE_PX, null, RATIOS);
|
|
const dbl = ZM.zoneSettingsForObject(BOX_PX, ZONE_PX, null, Object.assign({}, RATIOS, {fraction: 0.5}));
|
|
assert.strictEqual(base.MinAlarmPixels, 0.4);
|
|
assert.strictEqual(dbl.MinAlarmPixels, 0.8);
|
|
assert.strictEqual(dbl.MinBlobPixels, 0.48);
|
|
});
|
|
test('filter and blob ratios reach the stored fields', () => {
|
|
const s = ZM.zoneSettingsForObject(BOX_PX, ZONE_PX, null,
|
|
{fraction: 0.25, filter: 0.9, blob: 0.5});
|
|
assert.strictEqual(s.MinAlarmPixels, 0.4);
|
|
assert.strictEqual(s.MinFilterPixels, 0.36);
|
|
assert.strictEqual(s.MinBlobPixels, 0.18, 'half of filtered, not of alarmed');
|
|
});
|
|
test('the stored ordering holds for any pair of ratios', () => {
|
|
// validateForm rejects MinBlob > MinFilter or MinFilter > MinAlarm, which
|
|
// would leave the zone unsaveable.
|
|
[[0.3, 0.9], [0.9, 0.3], [1, 1], [0.05, 1]].forEach((pair) => {
|
|
const s = ZM.zoneSettingsForObject(BOX_PX, ZONE_PX, null,
|
|
{fraction: 0.25, filter: pair[0], blob: pair[1]});
|
|
assert.ok(s.MinAlarmPixels >= s.MinFilterPixels, 'alarm >= filter ' + pair);
|
|
assert.ok(s.MinFilterPixels >= s.MinBlobPixels, 'filter >= blob ' + pair);
|
|
});
|
|
});
|
|
test('null on degenerate geometry', () => {
|
|
assert.strictEqual(ZM.zoneSettingsForObject(0, ZONE_PX, null, RATIOS), null);
|
|
assert.strictEqual(ZM.zoneSettingsForObject(BOX_PX, 0, null, RATIOS), null);
|
|
});
|
|
|
|
console.log('zoneSettingsForObject: sensitivity is the user\'s, not the box\'s');
|
|
test('keeps a preset sensitivity instead of overwriting it', () => {
|
|
// "Best, low sensitivity" is threshold 60 and a 7x7 filter. Measuring an
|
|
// object must set the size from the box and leave that choice intact.
|
|
const s = ZM.zoneSettingsForObject(BOX_PX, ZONE_PX,
|
|
{MinPixelThreshold: '60', FilterX: '7', FilterY: '7'}, RATIOS);
|
|
assert.strictEqual(s.MinPixelThreshold, '60');
|
|
assert.strictEqual(s.FilterX, '7');
|
|
assert.strictEqual(s.FilterY, '7');
|
|
assert.strictEqual(s.MinAlarmPixels, 0.4, 'size still comes from the box');
|
|
});
|
|
test('falls back only where the field is blank', () => {
|
|
// A blank MinPixelThreshold or FilterX/Y blocks the save (validateForm:22,
|
|
// :27), so a fresh zone still has to be filled in. 40 is [G]'s starting
|
|
// point, above the Default preset's 25.
|
|
const s = ZM.zoneSettingsForObject(BOX_PX, ZONE_PX,
|
|
{MinPixelThreshold: '', FilterX: ''}, RATIOS);
|
|
assert.strictEqual(s.MinPixelThreshold, 40);
|
|
assert.strictEqual(s.FilterX, ZM.MIN_FILTER_KERNEL);
|
|
});
|
|
test('no prior state behaves as all blank', () => {
|
|
const s = ZM.zoneSettingsForObject(BOX_PX, ZONE_PX, null, RATIOS);
|
|
assert.strictEqual(s.MinPixelThreshold, 40);
|
|
assert.strictEqual(s.FilterY, ZM.MIN_FILTER_KERNEL);
|
|
});
|
|
test('a hand-tuned threshold survives too', () => {
|
|
// The rule is "already set", not "matches a preset".
|
|
const s = ZM.zoneSettingsForObject(BOX_PX, ZONE_PX, {MinPixelThreshold: '33'}, RATIOS);
|
|
assert.strictEqual(s.MinPixelThreshold, '33');
|
|
});
|
|
|
|
console.log('objectTooNarrowToFilter');
|
|
test('an object narrower than the kernel cannot be detected', () => {
|
|
// No fully-white window of that size fits, so the opening blacks out every
|
|
// pixel and the zone can never alarm.
|
|
assert.strictEqual(ZM.objectTooNarrowToFilter(2, 140, 3, 3), true);
|
|
});
|
|
test('an object that exactly fits the kernel survives', () => {
|
|
assert.strictEqual(ZM.objectTooNarrowToFilter(3, 140, 3, 3), false);
|
|
});
|
|
test('a normally-sized object is fine', () => {
|
|
assert.strictEqual(ZM.objectTooNarrowToFilter(60, 140, 3, 3), false);
|
|
});
|
|
test('judged against the kernel actually in use', () => {
|
|
// The regression this guards: a sensitivity preset sets 7x7, the tool now
|
|
// leaves that alone, and a 5px object is erased. Assuming 3x3 would miss it.
|
|
assert.strictEqual(ZM.objectTooNarrowToFilter(5, 140, 7, 7), true);
|
|
assert.strictEqual(ZM.objectTooNarrowToFilter(5, 140, 3, 3), false);
|
|
});
|
|
test('each side is judged against its own filter dimension', () => {
|
|
// A 15x3 kernel fits inside a 100x10 object, so nothing is erased.
|
|
assert.strictEqual(ZM.objectTooNarrowToFilter(100, 10, 15, 3), false);
|
|
// Turn the kernel on its side and the same object no longer fits it.
|
|
assert.strictEqual(ZM.objectTooNarrowToFilter(100, 10, 3, 15), true);
|
|
});
|
|
test('never reports safer than the 3x3 minimum', () => {
|
|
// A kernel below the legal minimum cannot make a 2px object detectable.
|
|
assert.strictEqual(ZM.objectTooNarrowToFilter(2, 140, 0, 0), true);
|
|
});
|
|
test('a wide flat object is erased when its height is the problem', () => {
|
|
assert.strictEqual(ZM.objectTooNarrowToFilter(400, 2, 3, 3), true);
|
|
});
|
|
|
|
console.log('normalizeRatio');
|
|
test('a whole number in range is kept', () => {
|
|
assert.strictEqual(ZM.normalizeRatio('25'), 25);
|
|
assert.strictEqual(ZM.normalizeRatio(1), 1);
|
|
assert.strictEqual(ZM.normalizeRatio(100), 100);
|
|
});
|
|
test('a fraction is rounded rather than truncated by validInt later', () => {
|
|
// zone.php reads the cookie back through validInt(), which strips the dot:
|
|
// 70.5 would return as 705 and mean something else entirely.
|
|
assert.strictEqual(ZM.normalizeRatio('70.5'), 71);
|
|
assert.strictEqual(ZM.normalizeRatio('70.4'), 70);
|
|
});
|
|
test('out of range and unreadable values are refused', () => {
|
|
// The caller falls back to the field's rendered default for these.
|
|
[0, -5, 101, '', 'abc', null, undefined, NaN].forEach((v) => {
|
|
assert.strictEqual(ZM.normalizeRatio(v), null, 'value ' + v);
|
|
});
|
|
});
|
|
test('exponent notation cannot smuggle a value through', () => {
|
|
// validInt('1e2') is 12, not 100. 100 is in range, so it has to round-trip
|
|
// as the digits that will be read back.
|
|
assert.strictEqual(ZM.normalizeRatio('1e2'), 100);
|
|
});
|
|
|
|
console.log('boxPercentForPixels');
|
|
// The frame the drag maps into: a 2560x1920 camera in the editor's 0..100
|
|
// percent space on both axes.
|
|
const FRAME = {width: 2560, height: 1920, maxX: 100, maxY: 100};
|
|
test('a typed size becomes a box of the same capture pixels', () => {
|
|
const box = ZM.boxPercentForPixels(256, 192, FRAME);
|
|
// A tenth of the frame on each axis.
|
|
assert.ok(Math.abs((box.b.x - box.a.x) - 10) < 1e-9);
|
|
assert.ok(Math.abs((box.b.y - box.a.y) - 10) < 1e-9);
|
|
});
|
|
test('the box is centred, so it is visible wherever the zone is', () => {
|
|
const box = ZM.boxPercentForPixels(256, 192, FRAME);
|
|
assert.ok(Math.abs(box.a.x - 45) < 1e-9);
|
|
assert.ok(Math.abs(box.a.y - 45) < 1e-9);
|
|
});
|
|
test('an object larger than the frame is clamped to it', () => {
|
|
// Nothing bigger than the image can be measured against it, and a box that
|
|
// ran off the edge would be drawn outside the drag's own coordinate space.
|
|
const box = ZM.boxPercentForPixels(9999, 9999, FRAME);
|
|
assert.strictEqual(box.a.x, 0);
|
|
assert.strictEqual(box.a.y, 0);
|
|
assert.strictEqual(box.b.x, 100);
|
|
assert.strictEqual(box.b.y, 100);
|
|
});
|
|
test('a missing or zero size measures nothing', () => {
|
|
[[0, 100], [100, 0], [NaN, 100], [-5, 5]].forEach((wh) => {
|
|
assert.strictEqual(ZM.boxPercentForPixels(wh[0], wh[1], FRAME), null,
|
|
'size ' + wh);
|
|
});
|
|
});
|
|
test('an unmeasured frame is refused rather than dividing by zero', () => {
|
|
// monitorData is not populated until the stream reports its size.
|
|
assert.strictEqual(ZM.boxPercentForPixels(50, 50, null), null);
|
|
assert.strictEqual(
|
|
ZM.boxPercentForPixels(50, 50, {width: 0, height: 0, maxX: 100, maxY: 100}),
|
|
null);
|
|
});
|
|
|
|
console.log('\n' + passed + ' passed, ' + failed + ' failed');
|
|
process.exit(failed ? 1 : 0);
|