Skip to content

Commit 29d3713

Browse files
fix: store zone thresholds as percentages and convert to pixels at runtime
Zone threshold fields (MinAlarmPixels, MaxAlarmPixels, MinFilterPixels, MaxFilterPixels, MinBlobPixels, MaxBlobPixels) were being corrupted on every save because the PHP conversion used monitor pixel area instead of zone pixel area. This caused values to inflate progressively, breaking motion detection. The fix changes the storage model: thresholds are now stored as percentages of zone area (DECIMAL(7,2) columns) matching the percentage coordinate system from zm_update-1.39.2. The C++ Zone::Load() converts percentages to pixel counts at runtime using polygon.Area(). Legacy pixel-coordinate zones pass through unchanged. JS changes: - submitForm() converts to percentages when in Pixels display mode - Init skips applyZoneUnits() since DB values are already percentages - limitRange() only updates field.value when constrained value differs, preventing oninput from stripping decimal points mid-keystroke fixes ZoneMinder#4690 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 62583ec commit 29d3713

File tree

5 files changed

+172
-38
lines changed

5 files changed

+172
-38
lines changed

db/zm_create.sql.in

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -887,14 +887,14 @@ CREATE TABLE `ZonePresets` (
887887
`CheckMethod` enum('AlarmedPixels','FilteredPixels','Blobs') NOT NULL default 'Blobs',
888888
`MinPixelThreshold` smallint(5) unsigned default NULL,
889889
`MaxPixelThreshold` smallint(5) unsigned default NULL,
890-
`MinAlarmPixels` int(10) unsigned default NULL,
891-
`MaxAlarmPixels` int(10) unsigned default NULL,
890+
`MinAlarmPixels` DECIMAL(7,2) unsigned default NULL,
891+
`MaxAlarmPixels` DECIMAL(7,2) unsigned default NULL,
892892
`FilterX` tinyint(3) unsigned default NULL,
893893
`FilterY` tinyint(3) unsigned default NULL,
894-
`MinFilterPixels` int(10) unsigned default NULL,
895-
`MaxFilterPixels` int(10) unsigned default NULL,
896-
`MinBlobPixels` int(10) unsigned default NULL,
897-
`MaxBlobPixels` int(10) unsigned default NULL,
894+
`MinFilterPixels` DECIMAL(7,2) unsigned default NULL,
895+
`MaxFilterPixels` DECIMAL(7,2) unsigned default NULL,
896+
`MinBlobPixels` DECIMAL(7,2) unsigned default NULL,
897+
`MaxBlobPixels` DECIMAL(7,2) unsigned default NULL,
898898
`MinBlobs` smallint(5) unsigned default NULL,
899899
`MaxBlobs` smallint(5) unsigned default NULL,
900900
`OverloadFrames` smallint(5) unsigned NOT NULL default '0',
@@ -921,14 +921,14 @@ CREATE TABLE `Zones` (
921921
`CheckMethod` enum('AlarmedPixels','FilteredPixels','Blobs') NOT NULL default 'Blobs',
922922
`MinPixelThreshold` smallint(5) unsigned default NULL,
923923
`MaxPixelThreshold` smallint(5) unsigned default NULL,
924-
`MinAlarmPixels` int(10) unsigned default NULL,
925-
`MaxAlarmPixels` int(10) unsigned default NULL,
924+
`MinAlarmPixels` DECIMAL(7,2) unsigned default NULL,
925+
`MaxAlarmPixels` DECIMAL(7,2) unsigned default NULL,
926926
`FilterX` tinyint(3) unsigned default NULL,
927927
`FilterY` tinyint(3) unsigned default NULL,
928-
`MinFilterPixels` int(10) unsigned default NULL,
929-
`MaxFilterPixels` int(10) unsigned default NULL,
930-
`MinBlobPixels` int(10) unsigned default NULL,
931-
`MaxBlobPixels` int(10) unsigned default NULL,
928+
`MinFilterPixels` DECIMAL(7,2) unsigned default NULL,
929+
`MaxFilterPixels` DECIMAL(7,2) unsigned default NULL,
930+
`MinBlobPixels` DECIMAL(7,2) unsigned default NULL,
931+
`MaxBlobPixels` DECIMAL(7,2) unsigned default NULL,
932932
`MinBlobs` smallint(5) unsigned default NULL,
933933
`MaxBlobs` smallint(5) unsigned default NULL,
934934
`OverloadFrames` smallint(5) unsigned NOT NULL default '0',

db/zm_update-1.39.3.sql

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,105 @@
1+
-- Convert zone threshold fields (MinAlarmPixels, etc.) from pixel counts
2+
-- to percentages of zone area, matching the coordinate percentage migration
3+
-- done in zm_update-1.39.2.sql.
4+
--
5+
6+
-- First convert existing pixel count values to percentages WHILE columns
7+
-- are still INT (values 0-100 fit in INT; ALTER to DECIMAL would fail on
8+
-- large pixel counts that exceed DECIMAL(7,2) max of 99999.99).
9+
--
10+
-- Zone pixel area = (Zones.Area * Monitors.Width * Monitors.Height) / 10000
11+
-- where Zones.Area is in percentage-space (0-10000) from zm_update-1.39.2.sql.
12+
-- new_percent = old_pixel_count * 100 / zone_pixel_area
13+
-- = old_pixel_count * 1000000 / (Zones.Area * Monitors.Width * Monitors.Height)
14+
--
15+
-- Only convert zones with percentage coordinates (contain '.') that still have
16+
-- pixel-scale threshold values (> 100 means it can't be a percentage).
17+
18+
UPDATE Zones z
19+
JOIN Monitors m ON z.MonitorId = m.Id
20+
SET
21+
z.MinAlarmPixels = CASE
22+
WHEN z.MinAlarmPixels IS NULL THEN NULL
23+
WHEN z.MinAlarmPixels = 0 THEN 0
24+
WHEN z.Area > 0 AND m.Width > 0 AND m.Height > 0
25+
THEN LEAST(ROUND(z.MinAlarmPixels * 1000000.0 / (z.Area * m.Width * m.Height)), 100)
26+
ELSE z.MinAlarmPixels END,
27+
z.MaxAlarmPixels = CASE
28+
WHEN z.MaxAlarmPixels IS NULL THEN NULL
29+
WHEN z.MaxAlarmPixels = 0 THEN 0
30+
WHEN z.Area > 0 AND m.Width > 0 AND m.Height > 0
31+
THEN LEAST(ROUND(z.MaxAlarmPixels * 1000000.0 / (z.Area * m.Width * m.Height)), 100)
32+
ELSE z.MaxAlarmPixels END,
33+
z.MinFilterPixels = CASE
34+
WHEN z.MinFilterPixels IS NULL THEN NULL
35+
WHEN z.MinFilterPixels = 0 THEN 0
36+
WHEN z.Area > 0 AND m.Width > 0 AND m.Height > 0
37+
THEN LEAST(ROUND(z.MinFilterPixels * 1000000.0 / (z.Area * m.Width * m.Height)), 100)
38+
ELSE z.MinFilterPixels END,
39+
z.MaxFilterPixels = CASE
40+
WHEN z.MaxFilterPixels IS NULL THEN NULL
41+
WHEN z.MaxFilterPixels = 0 THEN 0
42+
WHEN z.Area > 0 AND m.Width > 0 AND m.Height > 0
43+
THEN LEAST(ROUND(z.MaxFilterPixels * 1000000.0 / (z.Area * m.Width * m.Height)), 100)
44+
ELSE z.MaxFilterPixels END,
45+
z.MinBlobPixels = CASE
46+
WHEN z.MinBlobPixels IS NULL THEN NULL
47+
WHEN z.MinBlobPixels = 0 THEN 0
48+
WHEN z.Area > 0 AND m.Width > 0 AND m.Height > 0
49+
THEN LEAST(ROUND(z.MinBlobPixels * 1000000.0 / (z.Area * m.Width * m.Height)), 100)
50+
ELSE z.MinBlobPixels END,
51+
z.MaxBlobPixels = CASE
52+
WHEN z.MaxBlobPixels IS NULL THEN NULL
53+
WHEN z.MaxBlobPixels = 0 THEN 0
54+
WHEN z.Area > 0 AND m.Width > 0 AND m.Height > 0
55+
THEN LEAST(ROUND(z.MaxBlobPixels * 1000000.0 / (z.Area * m.Width * m.Height)), 100)
56+
ELSE z.MaxBlobPixels END
57+
WHERE z.Coords LIKE '%.%'
58+
AND (z.MinAlarmPixels > 100 OR z.MaxAlarmPixels > 100
59+
OR z.MinFilterPixels > 100 OR z.MaxFilterPixels > 100
60+
OR z.MinBlobPixels > 100 OR z.MaxBlobPixels > 100);
61+
62+
-- Now change threshold columns from int to DECIMAL(7,2) to store percentages
63+
-- with 2 decimal places (e.g. 25.50 = 25.50% of zone area).
64+
-- Values are now 0-100 from the UPDATE above, so they fit in DECIMAL(7,2).
65+
66+
SET @s = (SELECT IF(
67+
(SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = DATABASE()
68+
AND table_name = 'Zones' AND column_name = 'MinAlarmPixels'
69+
) = 'decimal',
70+
"SELECT 'Zones threshold columns already DECIMAL'",
71+
"ALTER TABLE `Zones`
72+
MODIFY `MinAlarmPixels` DECIMAL(7,2) unsigned default NULL,
73+
MODIFY `MaxAlarmPixels` DECIMAL(7,2) unsigned default NULL,
74+
MODIFY `MinFilterPixels` DECIMAL(7,2) unsigned default NULL,
75+
MODIFY `MaxFilterPixels` DECIMAL(7,2) unsigned default NULL,
76+
MODIFY `MinBlobPixels` DECIMAL(7,2) unsigned default NULL,
77+
MODIFY `MaxBlobPixels` DECIMAL(7,2) unsigned default NULL"
78+
));
79+
80+
PREPARE stmt FROM @s;
81+
EXECUTE stmt;
82+
DEALLOCATE PREPARE stmt;
83+
84+
-- Also update ZonePresets table column types for consistency
85+
SET @s = (SELECT IF(
86+
(SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = DATABASE()
87+
AND table_name = 'ZonePresets' AND column_name = 'MinAlarmPixels'
88+
) = 'decimal',
89+
"SELECT 'ZonePresets threshold columns already DECIMAL'",
90+
"ALTER TABLE `ZonePresets`
91+
MODIFY `MinAlarmPixels` DECIMAL(7,2) unsigned default NULL,
92+
MODIFY `MaxAlarmPixels` DECIMAL(7,2) unsigned default NULL,
93+
MODIFY `MinFilterPixels` DECIMAL(7,2) unsigned default NULL,
94+
MODIFY `MaxFilterPixels` DECIMAL(7,2) unsigned default NULL,
95+
MODIFY `MinBlobPixels` DECIMAL(7,2) unsigned default NULL,
96+
MODIFY `MaxBlobPixels` DECIMAL(7,2) unsigned default NULL"
97+
));
98+
99+
PREPARE stmt FROM @s;
100+
EXECUTE stmt;
101+
DEALLOCATE PREPARE stmt;
102+
1103
--
2104
-- Add Menu_Items table for customizable navbar/sidebar menu
3105
--

src/zm_zone.cpp

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -956,21 +956,21 @@ std::vector<Zone> Zone::Load(const std::shared_ptr<Monitor> &monitor) {
956956
col++;
957957
int MaxPixelThreshold = dbrow[col]?atoi(dbrow[col]):0;
958958
col++;
959-
int MinAlarmPixels = dbrow[col]?atoi(dbrow[col]):0;
959+
double MinAlarmPixels_pct = dbrow[col] ? atof(dbrow[col]) : 0;
960960
col++;
961-
int MaxAlarmPixels = dbrow[col]?atoi(dbrow[col]):0;
961+
double MaxAlarmPixels_pct = dbrow[col] ? atof(dbrow[col]) : 0;
962962
col++;
963963
int FilterX = dbrow[col]?atoi(dbrow[col]):0;
964964
col++;
965965
int FilterY = dbrow[col]?atoi(dbrow[col]):0;
966966
col++;
967-
int MinFilterPixels = dbrow[col]?atoi(dbrow[col]):0;
967+
double MinFilterPixels_pct = dbrow[col] ? atof(dbrow[col]) : 0;
968968
col++;
969-
int MaxFilterPixels = dbrow[col]?atoi(dbrow[col]):0;
969+
double MaxFilterPixels_pct = dbrow[col] ? atof(dbrow[col]) : 0;
970970
col++;
971-
int MinBlobPixels = dbrow[col]?atoi(dbrow[col]):0;
971+
double MinBlobPixels_pct = dbrow[col] ? atof(dbrow[col]) : 0;
972972
col++;
973-
int MaxBlobPixels = dbrow[col]?atoi(dbrow[col]):0;
973+
double MaxBlobPixels_pct = dbrow[col] ? atof(dbrow[col]) : 0;
974974
col++;
975975
int MinBlobs = dbrow[col]?atoi(dbrow[col]):0;
976976
col++;
@@ -1004,6 +1004,27 @@ std::vector<Zone> Zone::Load(const std::shared_ptr<Monitor> &monitor) {
10041004
}
10051005
}
10061006

1007+
// Convert threshold values from DB format to pixel counts for runtime use.
1008+
// Percentage coordinates: thresholds are stored as % of zone area, convert to pixels.
1009+
// Legacy pixel coordinates: thresholds are already pixel counts.
1010+
int MinAlarmPixels, MaxAlarmPixels, MinFilterPixels, MaxFilterPixels, MinBlobPixels, MaxBlobPixels;
1011+
if (strchr(Coords, '.') && polygon.Area() > 0) {
1012+
int zpa = polygon.Area();
1013+
MinAlarmPixels = MinAlarmPixels_pct > 0 ? static_cast<int>(MinAlarmPixels_pct * zpa / 100.0 + 0.5) : 0;
1014+
MaxAlarmPixels = MaxAlarmPixels_pct > 0 ? static_cast<int>(MaxAlarmPixels_pct * zpa / 100.0 + 0.5) : 0;
1015+
MinFilterPixels = MinFilterPixels_pct > 0 ? static_cast<int>(MinFilterPixels_pct * zpa / 100.0 + 0.5) : 0;
1016+
MaxFilterPixels = MaxFilterPixels_pct > 0 ? static_cast<int>(MaxFilterPixels_pct * zpa / 100.0 + 0.5) : 0;
1017+
MinBlobPixels = MinBlobPixels_pct > 0 ? static_cast<int>(MinBlobPixels_pct * zpa / 100.0 + 0.5) : 0;
1018+
MaxBlobPixels = MaxBlobPixels_pct > 0 ? static_cast<int>(MaxBlobPixels_pct * zpa / 100.0 + 0.5) : 0;
1019+
} else {
1020+
MinAlarmPixels = static_cast<int>(MinAlarmPixels_pct);
1021+
MaxAlarmPixels = static_cast<int>(MaxAlarmPixels_pct);
1022+
MinFilterPixels = static_cast<int>(MinFilterPixels_pct);
1023+
MaxFilterPixels = static_cast<int>(MaxFilterPixels_pct);
1024+
MinBlobPixels = static_cast<int>(MinBlobPixels_pct);
1025+
MaxBlobPixels = static_cast<int>(MaxBlobPixels_pct);
1026+
}
1027+
10071028
if (atoi(dbrow[2]) == Zone::INACTIVE) {
10081029
zones.emplace_back(monitor, Id, Name, polygon);
10091030
} else if (atoi(dbrow[2]) == Zone::PRIVACY) {

web/includes/actions/zone.php

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,9 @@
3131
$zone = array();
3232
}
3333

34-
if ( $_REQUEST['newZone']['Units'] == 'Percent' ) {
35-
// Convert percentage thresholds to pixel counts using actual monitor pixel area
36-
$pixelArea = $monitor->ViewWidth() * $monitor->ViewHeight();
37-
foreach (array(
38-
'MinAlarmPixels','MaxAlarmPixels',
39-
'MinFilterPixels','MaxFilterPixels',
40-
'MinBlobPixels','MaxBlobPixels'
41-
) as $field ) {
42-
if ( isset($_REQUEST['newZone'][$field]) and $_REQUEST['newZone'][$field] )
43-
$_REQUEST['newZone'][$field] = intval(($_REQUEST['newZone'][$field]*$pixelArea)/100);
44-
}
45-
}
34+
// Threshold fields (MinAlarmPixels, etc.) are always submitted as percentages
35+
// of zone area by the JavaScript submitForm() function. If displaying in Pixels
36+
// mode, submitForm() converts back to percentages before submitting.
4637

4738
unset($_REQUEST['newZone']['Points']);
4839

web/skins/classic/views/js/zone.js

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,18 @@ function submitForm(form) {
7171
form.elements['newZone[Coords]'].value = getCoordString();
7272
form.elements['newZone[Area]'].value = zone.Area;
7373

74+
// DB stores threshold values as percentages of zone area.
75+
// If displaying in Pixels mode, convert back to percentages before submitting.
76+
if (form.elements['newZone[Units]'].value == 'Pixels') {
77+
var pixelArea = Math.round(zone.Area / monitorArea * monitorPixelArea);
78+
toPercent(form.elements['newZone[MinAlarmPixels]'], pixelArea);
79+
toPercent(form.elements['newZone[MaxAlarmPixels]'], pixelArea);
80+
toPercent(form.elements['newZone[MinFilterPixels]'], pixelArea);
81+
toPercent(form.elements['newZone[MaxFilterPixels]'], pixelArea);
82+
toPercent(form.elements['newZone[MinBlobPixels]'], pixelArea);
83+
toPercent(form.elements['newZone[MaxBlobPixels]'], pixelArea);
84+
}
85+
7486
form.submit();
7587
}
7688

@@ -211,8 +223,8 @@ function toPercent(field, maxValue) {
211223

212224
function applyZoneUnits() {
213225
// zone.Area is in percentage-space (0-10000 for full frame)
214-
// Threshold fields are stored as pixel counts in the DB
215-
// Convert to pixel area for threshold display conversions
226+
// Threshold fields are stored as percentages of zone area in the DB
227+
// pixelArea is zone's actual pixel area, used for converting between display modes
216228
var pixelArea = Math.round(zone.Area / monitorArea * monitorPixelArea);
217229

218230
var form = document.zoneForm;
@@ -237,11 +249,11 @@ function applyZoneUnits() {
237249

238250
function limitRange(field, minValue, maxValue) {
239251
if ( field.value != '' ) {
240-
field.value = constrainValue(
241-
parseFloat(field.value),
242-
parseInt(minValue),
243-
parseInt(maxValue)
244-
);
252+
var currentValue = parseFloat(field.value);
253+
var constrainedValue = constrainValue(currentValue, parseInt(minValue), parseInt(maxValue));
254+
if ( constrainedValue !== currentValue ) {
255+
field.value = constrainedValue;
256+
}
245257
}
246258
}
247259

@@ -669,7 +681,15 @@ function initPage() {
669681
applyZoneType();
670682

671683
if ( form.elements['newZone[Units]'].value == 'Percent' ) {
672-
applyZoneUnits();
684+
// DB stores threshold values as percentages of zone area.
685+
// In Percent mode, values are already correct; just set Area display and field attributes.
686+
form.elements['newZone[Area]'].value = Math.round(zone.Area / monitorArea * 100);
687+
var thresholdFields = ['MinAlarmPixels', 'MaxAlarmPixels', 'MinFilterPixels', 'MaxFilterPixels', 'MinBlobPixels', 'MaxBlobPixels'];
688+
for (var i = 0; i < thresholdFields.length; i++) {
689+
var field = form.elements['newZone[' + thresholdFields[i] + ']'];
690+
field.setAttribute('step', 'any');
691+
field.setAttribute('max', 100);
692+
}
673693
}
674694

675695
applyCheckMethod();

0 commit comments

Comments
 (0)