juham-watermeter 0.0.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ """
2
+ Description
3
+ ===========
4
+
5
+ Web-camera based watermeter classes with leak detector
6
+
7
+ """
8
+
9
+ from .webcamera import WebCamera
10
+ from .watermeter_ocr import WaterMeterOCR
11
+ from .watermeter_imgdiff import WaterMeterImgDiff
12
+ from .watermeter_ts import WaterMeterTs
13
+ from .leakdetector import LeakDetector
14
+
15
+ __all__ = [
16
+ "WebCamera",
17
+ "WaterMeterOCR",
18
+ "WaterMeterImgDiff",
19
+ "WaterMeterTs",
20
+ "LeakDetector",
21
+ ]
@@ -0,0 +1,170 @@
1
+ """
2
+ Leak Detector based on Motion Detection and Water Meter Monitoring.
3
+
4
+ This class listens to water meter and motion sensor topics to detect potential water leaks.
5
+ If a leak is detected, the system triggers a leak alarm.
6
+ """
7
+
8
+ import json
9
+ from typing import Any
10
+ from typing_extensions import override
11
+ from masterpiece.mqtt import MqttMsg
12
+ from juham_core.timeutils import timestamp
13
+ from juham_core import Juham
14
+
15
+
16
+ class LeakDetector(Juham):
17
+ """
18
+ Water Leak Detector Class
19
+
20
+ Listens to water meter and motion sensor topics to identify potential water leaks.
21
+ If water consumption is detected without corresponding motion, or if water usage
22
+ remains constant for prolonged periods, a leak alarm is triggered.
23
+
24
+ Detection considers the time since the last motion detection and compares it to
25
+ the configured leak detection period, which is the maximum runtime of water-consuming
26
+ appliances.
27
+ """
28
+
29
+ _LEAKDETECTOR: str = "leakdetector"
30
+ _LEAKDETECTOR_ATTRS: list[str] = [
31
+ "watermeter_topic",
32
+ "motion_topic",
33
+ "motion_last_detected_ts",
34
+ ]
35
+
36
+ watermeter_topic: str = "watermeter"
37
+ motion_topic: str = "motion"
38
+ leak_detection_period: float = (
39
+ 3 * 3600.0
40
+ ) # Maximum runtime for appliances, in seconds
41
+ location: str = "home"
42
+ conseq_zero_periods: int = (
43
+ 60 # this many subsequent zero flow reports imply no leak
44
+ )
45
+
46
+ def __init__(self, name: str = "leakdetector") -> None:
47
+ """
48
+ Initialize the leak detector.
49
+
50
+ Args:
51
+ name (str, optional): Name of the detector instance. Defaults to "leakdetector".
52
+ """
53
+ super().__init__(name)
54
+ self.motion_last_detected_ts: float = timestamp()
55
+ self.watermeter_full_topic: str = self.make_topic_name(self.watermeter_topic)
56
+ self.motion_full_topic: str = self.make_topic_name(self.motion_topic)
57
+ self.leak_detected: bool = False
58
+ self.zero_usage_periods_count: int = 0
59
+
60
+ @override
61
+ def on_connect(self, client: object, userdata: Any, flags: int, rc: int) -> None:
62
+ """
63
+ Handle MQTT connection. Subscribe to water meter and motion topics.
64
+ """
65
+ super().on_connect(client, userdata, flags, rc)
66
+ if rc == 0:
67
+ self.subscribe(self.watermeter_full_topic)
68
+ self.subscribe(self.motion_full_topic)
69
+
70
+ @override
71
+ def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
72
+ """
73
+ Process incoming MQTT messages for water meter and motion topics.
74
+ """
75
+ payload = json.loads(msg.payload.decode())
76
+ if msg.topic == self.watermeter_full_topic:
77
+ self.process_water_meter_data(payload)
78
+ elif msg.topic == self.motion_full_topic:
79
+ self.process_motion_data(payload)
80
+ else:
81
+ super().on_message(client, userdata, msg)
82
+
83
+ def detect_activity(self, current_ts: float) -> bool:
84
+ """
85
+ Check if activity (motion) has been detected within the leak detection period.
86
+
87
+ Args:
88
+ current_ts (float): Current timestamp.
89
+
90
+ Returns:
91
+ bool: True if activity detected within the period, False otherwise.
92
+ """
93
+ elapsed_time = current_ts - self.motion_last_detected_ts
94
+ return elapsed_time < self.leak_detection_period
95
+
96
+ def publish_leak_status(self, current_ts: float, leak_suspected: bool) -> None:
97
+ """
98
+ Publish the leak detection status.
99
+
100
+ Args:
101
+ current_ts (float): Current timestamp.
102
+ leak_suspected (bool): Whether a leak is suspected.
103
+ """
104
+ status = {
105
+ "location": self.location,
106
+ "sensor": self.name,
107
+ "leak_suspected": leak_suspected,
108
+ "ts": current_ts,
109
+ }
110
+ payload = json.dumps(status)
111
+ self.publish(self.watermeter_full_topic, payload, qos=1, retain=False)
112
+
113
+ def process_water_meter_data(self, data: dict[str, float]) -> None:
114
+ """
115
+ Handle water meter data and apply leak detection logic.
116
+
117
+ Args:
118
+ data (dict): Water meter data containing flow rate and timestamp.
119
+ """
120
+ if "active_lpm" in data and "ts" in data:
121
+ flow_rate = data["active_lpm"]
122
+ current_ts = data["ts"]
123
+
124
+ if flow_rate > 0.0:
125
+ if not self.detect_activity(current_ts):
126
+ if not self.leak_detected:
127
+ self.leak_detected = True
128
+ # Optionally trigger external alarm here (e.g., email notification)
129
+ self.warning(
130
+ f"LEAK SUSPECT",
131
+ "Flow {flow_rate} lpm, last detected motion {self.amotion_last_detected_tss}",
132
+ )
133
+
134
+ self.zero_usage_periods_count = 0
135
+ # self.debug(f"LeakDetector: flow {flow_rate} liters per minute detected")
136
+ else:
137
+ self.zero_usage_periods_count += 1
138
+ if self.zero_usage_periods_count > self.conseq_zero_periods:
139
+ self.leak_detected = False
140
+ self.motion_last_detected_ts = current_ts
141
+ # self.debug(
142
+ # f"Leak status reset after {self.zero_usage_periods_count} subsequent reports with no flow"
143
+ # )
144
+ self.publish_leak_status(current_ts, self.leak_detected)
145
+
146
+ def process_motion_data(self, data: dict[str, float]) -> None:
147
+ """
148
+ Update the last detected motion timestamp.
149
+
150
+ Args:
151
+ data (dict): Motion sensor data containing timestamp.
152
+ """
153
+ if "motion" in data and data["motion"]:
154
+ # self.debug(f"Leak detection period reset, motion detected")
155
+ self.motion_last_detected_ts = data["ts"]
156
+
157
+ @override
158
+ def to_dict(self) -> dict[str, Any]:
159
+ data = super().to_dict()
160
+ attributes = {attr: getattr(self, attr) for attr in self._LEAKDETECTOR_ATTRS}
161
+ data[self._LEAKDETECTOR] = attributes
162
+ return data
163
+
164
+ @override
165
+ def from_dict(self, data: dict[str, Any]) -> None:
166
+ super().from_dict(data)
167
+ if self._LEAKDETECTOR in data:
168
+ attributes = data[self._LEAKDETECTOR]
169
+ for attr in self._LEAKDETECTOR_ATTRS:
170
+ setattr(self, attr, attributes.get(attr, None))
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,328 @@
1
+ """Web camera based optical watermeter based on differences between subsequent frames.
2
+
3
+ """
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import tempfile
9
+ import time
10
+ import cv2
11
+ import numpy as np
12
+ from typing import Any, Optional, Union, cast
13
+ from typing_extensions import override
14
+ from masterpiece.mqtt import Mqtt
15
+
16
+ from juham_core.timeutils import timestamp
17
+ from .webcamera import WebCameraThread, WebCamera
18
+
19
+
20
+ class WaterMeterThreadImgDiff(WebCameraThread):
21
+ """Asynchronous thread for capturing and processing images of web camera.
22
+ Uploads three images to sftp server for inspection: the original watermeter
23
+ image, the image holding differences between the last captured watermeter image,
24
+ and processed image with noise eliminated, and differences scaled up for maximum
25
+ image contrast: white pixels represent areas. The more white pixels, the bigger the
26
+ water consumption.
27
+ """
28
+
29
+ # class attributes
30
+ _watermeter_topic: str = ""
31
+ _expected_image_size: int = 640 * 480
32
+ _save_images: bool = True
33
+ _calibration_factor: float = 1000.0 # img diff to liters
34
+ _max_change_area: float = 0.1 # difference can't be greater than this
35
+ ftp_site: str = ""
36
+ ftp_user: str = ""
37
+ ftp_pw: str = ""
38
+
39
+ def __init__(self, client: Optional[Mqtt] = None):
40
+ """Construct with the given mqtt client.
41
+
42
+ Args:
43
+ client (object, optional): MQTT client. Defaults to None.
44
+ """
45
+ super().__init__(client)
46
+ self.sensor_name = "watermeter_imgdiff"
47
+ self.mqtt_client: Optional[Mqtt] = client
48
+ self.total_liter: float = 0.0
49
+ self.active_liter_lpm: float = 0.0
50
+ self._prev_image: np.ndarray = np.zeros((1, 1, 3), dtype=np.uint8)
51
+ self._prev_image_initialized = False
52
+ self._prev_time: float = 0.0
53
+
54
+ # total cumulative diff between two consequtive images
55
+ self._wm_start_seconds: float = 0.0
56
+
57
+ # temp filenames for saving the original and processed images, for debugging purposes
58
+ self._temp_filename1: str = ""
59
+ self._temp_filename2: str = ""
60
+
61
+ def init_watermeter_imgdiff(
62
+ self,
63
+ interval: float,
64
+ location: str,
65
+ camera: int,
66
+ topic: str,
67
+ save_images: bool,
68
+ ) -> None:
69
+ """Initialize the data acquisition thread
70
+
71
+ Args:
72
+ topic (str): mqtt topic to publish the acquired system info
73
+ interval (float): update interval in seconds
74
+ location (str): geographic location
75
+ camera(int) : ordinal specifying the camera to be used (0, 1)
76
+ save_images (bool) : true to enable saving of captured images, for debugging
77
+ """
78
+ super().init(interval, location, camera)
79
+ self._watermeter_topic = topic
80
+ self._save_images = save_images
81
+ # temp filenames for saving the original and processed images, for debugging purposes
82
+ tmpdir: str = tempfile.mkdtemp()
83
+ self._temp_filename1 = os.path.join(tmpdir, f"{self.sensor_name}_wm_1.png")
84
+ self._temp_filename2 = os.path.join(tmpdir, f"{self.sensor_name}_wm_2.png")
85
+
86
+ def upload_image(self, file: str, img: np.ndarray) -> None:
87
+ """Save the image to the given file and upload the file to the FTP server.
88
+
89
+ Args:
90
+ file (str): The filename where the image will be saved.
91
+ img (np.ndarray): The image to be saved and uploaded.
92
+ """
93
+ # Save the image to the specified file
94
+ cv2.imwrite(file, img)
95
+
96
+ try:
97
+ # Upload the file to the FTP server
98
+ self.upload_file(file)
99
+
100
+ # If the upload is successful, remove the file
101
+ os.remove(file)
102
+ print(f"File {file} uploaded and deleted successfully.")
103
+
104
+ except Exception as e:
105
+ # Handle any errors that occurred during upload
106
+ self.error(f"Error during file upload: {e}")
107
+
108
+ def compare_images(
109
+ self,
110
+ np_prev: np.ndarray,
111
+ np_current: np.ndarray,
112
+ threshold: int = 20,
113
+ ) -> float:
114
+ """
115
+ Compares two images and returns a float value representing the level of differences.
116
+
117
+ Parameters:
118
+ np_prev (np.ndarray): The previous image.
119
+ np_current (np.ndarray): The current image.
120
+ threshold (int): Threshold value to filter noise in the difference image.
121
+
122
+ Returns:
123
+ float: A value between 0.0 and 1.0 indicating the level of difference.
124
+ 0.0 means identical, 1.0 means maximally different.
125
+ """
126
+ # Ensure both images are the same size and type
127
+ if np_prev.shape != np_current.shape:
128
+ self.error("Images have different shapes or dimensions.")
129
+ return -1.0 # Error value for different shapes
130
+
131
+ # Step 1: Calculate the absolute difference between the two images
132
+ diff_image = cv2.absdiff(np_prev, np_current)
133
+
134
+ # Step 2: Filter small differences (noise) using a binary threshold
135
+ _, binary_diff = cv2.threshold(diff_image, threshold, 255, cv2.THRESH_BINARY)
136
+
137
+ # Step 3: Calculate the proportion of changed pixels
138
+ change_area: float = np.count_nonzero(binary_diff) / binary_diff.size
139
+
140
+ if change_area > 0.0:
141
+ print(f"Watermeter change {change_area}, uploading")
142
+ self.upload_images(np_current, binary_diff)
143
+
144
+ # Return a value between 0.0 (identical) and 1.0 (maximally different)
145
+ return min(max(change_area, 0.0), 1.0)
146
+
147
+ @override
148
+ def update_interval(self) -> float:
149
+ return self._interval
150
+
151
+ def upload_file(self, filename: str) -> None:
152
+ """Upload the given filename to the ftp server, if the server is configured.
153
+
154
+ Args:
155
+ filename (str): _description_
156
+ """
157
+ if len(self.ftp_site) > 0 and len(self.ftp_user) > 0 and len(self.ftp_pw) > 0:
158
+
159
+ # Build the curl command for uploading the file
160
+ curl_command = [
161
+ "curl",
162
+ f"-u{self.ftp_user}:{self.ftp_pw}",
163
+ "--retry",
164
+ "3",
165
+ "--retry-delay",
166
+ "5",
167
+ "-T",
168
+ filename,
169
+ self.ftp_site,
170
+ ]
171
+
172
+ # Execute the curl command
173
+ try:
174
+ subprocess.run(curl_command, check=True)
175
+ except subprocess.CalledProcessError as e:
176
+ self.error(f"Error during image upload: {e}")
177
+
178
+ def upload_images(self, np_watermeter: np.ndarray, np_diff: np.ndarray) -> None:
179
+ """Upload captured grayscale watermeter image, and the diff image to ftp site
180
+
181
+ Parameters:
182
+ np_watermeter (np.ndarray): Watermeter image in grayscale
183
+ np_diff (np.ndarray): The diff image reflecting consumed water
184
+ """
185
+ self.upload_image(self._temp_filename1, np_watermeter)
186
+ self.upload_image(self._temp_filename2, np_diff)
187
+
188
+ @override
189
+ def update(self) -> bool:
190
+ change_area: float = -1
191
+ captured_image = self.capture_image()
192
+ if captured_image.size < self._expected_image_size:
193
+ return False
194
+ grayscale_image = self.process_image(captured_image)
195
+ if grayscale_image.size < self._expected_image_size:
196
+ return False
197
+ processed_image = self.enhance_contrast(grayscale_image)
198
+ if processed_image.size < self._expected_image_size:
199
+ return False
200
+
201
+ if self._prev_image.size == self._expected_image_size:
202
+ change_area = self.compare_images(self._prev_image, processed_image)
203
+ else:
204
+ self._prev_image = processed_image
205
+ return True
206
+
207
+ lpm: float = 0.0
208
+
209
+ if change_area > 0.0:
210
+ # to capture even the smallest leaks, update the previous image
211
+ # only when difference is found
212
+ self._prev_image = processed_image
213
+ wm_elapsed_seconds: float = time.time() - self._wm_start_seconds
214
+ self._wm_start_seconds = time.time()
215
+
216
+ # image change_area factor to consumed water in liters
217
+ liters = change_area * self._calibration_factor
218
+
219
+ # update cumulative water consumption
220
+ self.total_liter += liters / 1000.0
221
+
222
+ # scale liters to flow (liters per minute)
223
+ lpm = liters / (wm_elapsed_seconds / 60.0)
224
+
225
+ watermeter: dict[str, Union[float, str]] = {
226
+ "location": self._location,
227
+ "sensor": self.sensor_name,
228
+ "total_liter": self.total_liter,
229
+ "active_lpm": lpm,
230
+ "ts": timestamp(),
231
+ }
232
+
233
+ msg = json.dumps(watermeter)
234
+ self.publish(self._watermeter_topic, msg, qos=0, retain=False)
235
+ return True
236
+
237
+
238
+ class WaterMeterImgDiff(WebCamera):
239
+ """WebCamera based optical watermeter. Needs a low-cost web camera and a spot
240
+ light to illuminate the watermeter. Captures images with specified interval (the default
241
+ is 1 minute) and computes a factor that represents the level of difference, 0.0 being no
242
+ differences and 1.0 corresponding to the maximum difference (all pixels different with
243
+ maximum contrast e.g. 0 vs 255).
244
+ The more two consequtive images differ, the higher the water consumption. Cannot give any absolute water consumption
245
+ measurements as liters, but suits well for leak detection purposes - the greater
246
+ the difference the creater the water consumption.
247
+
248
+ """
249
+
250
+ _WATERMETER: str = "watermeter_imgdiff"
251
+ _WATERMETER_ATTRS: list[str] = [
252
+ "topic",
253
+ "update_interval",
254
+ "location",
255
+ "camera",
256
+ "save_images",
257
+ ]
258
+
259
+ _workerThreadId: str = WaterMeterThreadImgDiff.get_class_id()
260
+ update_interval: float = 30
261
+ topic = "watermeter"
262
+ location = "home"
263
+ camera: int = 0
264
+ save_images: bool = True
265
+
266
+ def __init__(self, name="watermeter_imgdiff") -> None:
267
+ """Constructs system status automation object for acquiring and publishing
268
+ system info e.g. available memory and CPU loads.
269
+
270
+ Args:
271
+ name (str, optional): name of the object.
272
+ """
273
+ super().__init__(name)
274
+ self.worker: Optional[WaterMeterThreadImgDiff] = None
275
+ self.watermeter_topic: str = self.make_topic_name(self.topic)
276
+
277
+ @override
278
+ def initialize(self) -> None:
279
+ # let the super class to initialize database first so that we can read it
280
+ super().initialize()
281
+
282
+ # read the latest known value from
283
+ last_value: dict[str, float] = self.read_last_value(
284
+ "watermeter",
285
+ {"sensor": self.name, "location": self.location},
286
+ ["total_liter"],
287
+ )
288
+ worker: WaterMeterThreadImgDiff = cast(WaterMeterThreadImgDiff, self.worker)
289
+ if "total_liter" in last_value:
290
+ worker.total_liter = last_value["total_liter"]
291
+ self.info(f"Total liters {worker.total_liter} read from the database")
292
+ else:
293
+ self.warning("no previous database value for total_liter found")
294
+
295
+ @override
296
+ def run(self) -> None:
297
+ # create, initialize and start the asynchronous thread for acquiring forecast
298
+
299
+ self.worker = cast(
300
+ WaterMeterThreadImgDiff, self.instantiate(WaterMeterImgDiff._workerThreadId)
301
+ )
302
+ self.worker.sensor_name = self.name
303
+
304
+ self.worker.init_watermeter_imgdiff(
305
+ self.update_interval,
306
+ self.location,
307
+ self.camera,
308
+ self.watermeter_topic,
309
+ self.save_images,
310
+ )
311
+ super().run()
312
+
313
+ @override
314
+ def to_dict(self) -> dict[str, Any]:
315
+ data = super().to_dict() # Call parent class method
316
+ watermeter_data = {}
317
+ for attr in self._WATERMETER_ATTRS:
318
+ watermeter_data[attr] = getattr(self, attr)
319
+ data[self._WATERMETER] = watermeter_data
320
+ return data
321
+
322
+ @override
323
+ def from_dict(self, data: dict[str, Any]) -> None:
324
+ super().from_dict(data) # Call parent class method
325
+ if self._WATERMETER in data:
326
+ watermeter_data = data[self._WATERMETER]
327
+ for attr in self._WATERMETER_ATTRS:
328
+ setattr(self, attr, watermeter_data.get(attr, None))
@@ -0,0 +1,273 @@
1
+ """Optical Character Recognition based water meter
2
+ Note: tested and works, but needs more work to be reliable
3
+
4
+ """
5
+
6
+ import json
7
+ import time
8
+ import cv2
9
+ import numpy as np
10
+ from typing import Any, Optional, Union, cast
11
+ from typing_extensions import override
12
+ import pytesseract # type: ignore
13
+ from PIL import Image
14
+ from masterpiece.mqtt import Mqtt
15
+ from juham_core.timeutils import timestamp
16
+ from .webcamera import WebCameraThread, WebCamera
17
+
18
+
19
+ class WaterMeterThreadOCR(WebCameraThread):
20
+ """Asynchronous thread for capturing and processing images of web camera."""
21
+
22
+ # class attributes
23
+ _watermeter_topic: str = ""
24
+ _expected_image_size: int = 640 * 480
25
+ _crop_x: int = 195
26
+ _crop_y: int = 157
27
+ _crop_width: int = 640
28
+ _crop_height: int = 480
29
+ _save_images: bool = True
30
+ _num_digits: int = 5
31
+
32
+ def __init__(self, client: Optional[Mqtt] = None):
33
+ """Construct with the given mqtt client.
34
+
35
+ Args:
36
+ client (object, optional): MQTT client. Defaults to None.
37
+ """
38
+ super().__init__(client)
39
+ self.mqtt_client: Optional[Mqtt] = client
40
+ self.total_liter: float = 0.0
41
+ self.active_liter_lpm: float = 0.0
42
+ self._prev_time: float = (
43
+ 0.0 # for computing momentary consumption (liters per hour)
44
+ )
45
+
46
+ def init_watermeter_ocr(
47
+ self,
48
+ topic: str,
49
+ interval: float,
50
+ location: str,
51
+ camera: int,
52
+ crop_x: int,
53
+ crop_y: int,
54
+ crop_width: int,
55
+ crop_height: int,
56
+ save_images: bool,
57
+ num_digits: int,
58
+ ) -> None:
59
+ """Initialize the data acquisition thread
60
+
61
+ Args:
62
+ topic (str): mqtt topic to publish the acquired system info
63
+ interval (float): update interval in seconds
64
+ location (str): geographic location
65
+ camera(int) : ordinal specifying the camera to be used (0, 1)
66
+ crop_x, crop_y, crop_width, crop_height (int): crop box
67
+ save_images (bool) : true to enable saving of captured images, for debugging
68
+ num_digits (int) : number of digits in the watermeter
69
+ """
70
+ super().init(interval, location, camera)
71
+ self._watermeter_topic = topic
72
+ self._crop_x = crop_x
73
+ self._crop_y = crop_y
74
+ self._crop_width = crop_width
75
+ self._crop_height = crop_height
76
+ self._save_images = save_images
77
+ self._num_digits = num_digits
78
+
79
+ @override
80
+ def update_interval(self) -> float:
81
+ return self._interval
82
+
83
+ @override
84
+ def update(self) -> bool:
85
+ captured_image = self.capture_image()
86
+ if captured_image.size < self._expected_image_size:
87
+ return False
88
+ processed_image = self.process_image(captured_image)
89
+ if processed_image.size < self._expected_image_size:
90
+ return False
91
+
92
+ value: float = self.recognize_text(processed_image)
93
+ if value < self.total_liter:
94
+ self.warning("Invalid watermeter reading {value} skipped")
95
+
96
+ self.total_liter = value
97
+ current_time: float = time.time()
98
+ elapsed_seconds: float = current_time - self._prev_time
99
+ self._prev_time = current_time
100
+ liters_per_minute = value / (60 * elapsed_seconds)
101
+ watermeter: dict[str, Union[float, str]] = {
102
+ "location": self._location,
103
+ "sensor": self.name,
104
+ "total_liter": self.total_liter,
105
+ "active_lpm": liters_per_minute,
106
+ "ts": timestamp(),
107
+ }
108
+
109
+ msg = json.dumps(watermeter)
110
+ self.publish(self._watermeter_topic, msg, qos=0, retain=False)
111
+ self.debug(f"Watermeter published to {self._watermeter_topic}", msg)
112
+ return True
113
+
114
+ def evaluate_text(self, text: str) -> float:
115
+ # make sure we got all the digits
116
+ num_lines: int = len(text.splitlines())
117
+ if num_lines != 2:
118
+ print(f"{text} has invalid number of lines {num_lines}")
119
+ return 0.0
120
+ first_line = text.splitlines()[0]
121
+ num_digits: int = len(first_line)
122
+
123
+ if num_digits != self._num_digits:
124
+ print(
125
+ f"{text} has invalid number of digits {num_digits}, expected {self._num_digits}"
126
+ )
127
+ return 0.0
128
+ try:
129
+ num = float(first_line)
130
+ self.debug(f"Evaluated string {first_line} as {num}")
131
+ print(f"Evaluated string {first_line} as {num}")
132
+ return num
133
+ except ValueError:
134
+ self.warning(f"Cannot evaluate string {first_line}")
135
+ print(f"Cannot evaluated string {first_line}")
136
+
137
+ return 0.0
138
+
139
+ def recognize_text(self, greyscale_image: np.ndarray) -> float:
140
+ """Recognize numerical digits from the given greyscale image.
141
+
142
+ Args:
143
+ greyscale_image (np.ndarray): image to be recognized
144
+
145
+ Returns:
146
+ float: recognized value.
147
+ """
148
+
149
+ # cv2.imwrite("full.jpg", greyscale_image)
150
+
151
+ # Apply a mask to focus only on the digits
152
+ mask = np.zeros_like(greyscale_image)
153
+ cv2.rectangle(
154
+ mask,
155
+ (self._crop_x, self._crop_y),
156
+ (self._crop_x + self._crop_width, self._crop_y + self._crop_height),
157
+ 255,
158
+ -1,
159
+ ) # White rectangle for the ROI
160
+ masked_image = cv2.bitwise_and(greyscale_image, mask)
161
+ # cv2.imwrite("masked.jpg", masked_image)
162
+
163
+ # Crop the ROI for OCR
164
+ cropped_roi = masked_image[
165
+ self._crop_y : self._crop_y + self._crop_height,
166
+ self._crop_x : self._crop_x + self._crop_width,
167
+ ]
168
+
169
+ # Step 1: Apply a Gaussian blur to smooth out small details
170
+ blurred_image = cv2.GaussianBlur(cropped_roi, (5, 5), 0)
171
+
172
+ # Step 2: Apply adaptive thresholding for better OCR
173
+ thresholded_image = cv2.adaptiveThreshold(
174
+ blurred_image, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 11, 2
175
+ )
176
+
177
+ # Step 3: Use morphological operations to remove thin vertical lines
178
+ kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2))
179
+ morph_image = cv2.morphologyEx(thresholded_image, cv2.MORPH_CLOSE, kernel)
180
+
181
+ # Save the intermediate image for debugging
182
+ # cv2.imwrite("preprocessed.jpg", morph_image)
183
+
184
+ # Convert to PIL image for pytesseract
185
+ pil_image = Image.fromarray(morph_image)
186
+
187
+ # Perform OCR with digits only
188
+ text = pytesseract.image_to_string(
189
+ pil_image, config="--psm 6 -c tessedit_char_whitelist=0123456789"
190
+ )
191
+
192
+ return self.evaluate_text(text)
193
+
194
+
195
+ class WaterMeterOCR(WebCamera):
196
+ """Constructs a data acquisition thread for reading system status
197
+ info, e.g. available disk space and publishes the data to the watermeter topic.
198
+
199
+ """
200
+
201
+ _WATERMETER: str = "watermeter_ocr"
202
+ _WATERMETER_ATTRS: list[str] = [
203
+ "topic",
204
+ "update_interval",
205
+ "location",
206
+ "crop_x",
207
+ "crop_y",
208
+ "crop_width",
209
+ "crop_height",
210
+ "save_images",
211
+ "num_digits",
212
+ ]
213
+
214
+ _workerThreadId: str = WaterMeterThreadOCR.get_class_id()
215
+ update_interval: float = 60
216
+ topic = "watermeter"
217
+ location = "home"
218
+ camera: int = 0
219
+ crop_x: int = 0
220
+ crop_y: int = 0
221
+ crop_width: int = 640
222
+ crop_height: int = 480
223
+ save_images: bool = True
224
+ num_digits: int = 5
225
+
226
+ def __init__(self, name="watermeter_ocr") -> None:
227
+ """Constructs system status automation object for acquiring and publishing
228
+ system info e.g. available memory and CPU loads.
229
+
230
+ Args:
231
+ name (str, optional): name of the object.
232
+ """
233
+ super().__init__(name)
234
+ self.worker: Optional[WaterMeterThreadOCR] = None
235
+ self.watermeter_topic: str = self.make_topic_name(self.topic)
236
+
237
+ @override
238
+ def run(self) -> None:
239
+ # create, initialize and start the asynchronous thread for acquiring forecast
240
+
241
+ self.worker = cast(
242
+ WaterMeterThreadOCR, self.instantiate(WaterMeterOCR._workerThreadId)
243
+ )
244
+ self.worker.name = self.name
245
+
246
+ self.worker.init_watermeter_ocr(
247
+ self.watermeter_topic,
248
+ self.update_interval,
249
+ self.location,
250
+ self.camera,
251
+ self.crop_x,
252
+ self.crop_y,
253
+ self.crop_width,
254
+ self.crop_height,
255
+ self.save_images,
256
+ self.num_digits,
257
+ )
258
+ super().run()
259
+
260
+ def to_dict(self) -> dict[str, Any]:
261
+ data = super().to_dict() # Call parent class method
262
+ watermeter_data = {}
263
+ for attr in self._WATERMETER_ATTRS:
264
+ watermeter_data[attr] = getattr(self, attr)
265
+ data[self._WATERMETER] = watermeter_data
266
+ return data
267
+
268
+ def from_dict(self, data: dict[str, Any]) -> None:
269
+ super().from_dict(data) # Call parent class method
270
+ if self._WATERMETER in data:
271
+ watermeter_data = data[self._WATERMETER]
272
+ for attr in self._WATERMETER_ATTRS:
273
+ setattr(self, attr, watermeter_data.get(attr, None))
@@ -0,0 +1,100 @@
1
+ """Water meter time series recorder
2
+
3
+ """
4
+
5
+ import json
6
+
7
+ from typing import Any
8
+ from typing_extensions import override
9
+ from masterpiece.mqtt import MqttMsg
10
+
11
+ from juham_core.timeutils import epoc2utc
12
+ from juham_core import JuhamTs
13
+
14
+
15
+ class WaterMeterTs(JuhamTs):
16
+ """Watermeter timeseries recorder. Listens the watermeter MQTT topic
17
+ and writes the measurements to timeseries database.
18
+
19
+ """
20
+
21
+ _WATERMETER: str = "watermeter_ts"
22
+ _WATERMETER_ATTRS: list[str] = [
23
+ "topic",
24
+ ]
25
+
26
+ topic = "watermeter" # topic to listen
27
+
28
+ def __init__(self, name="watermeter_ts") -> None:
29
+ """Watermeter recorder, for listening watermeter MQTT topic and saving
30
+ measurements to timeseries database.
31
+
32
+ Args:
33
+ name (str, optional): name of the object.
34
+ """
35
+ super().__init__(name)
36
+ self.watermeter_topic: str = self.make_topic_name(self.topic)
37
+
38
+ @override
39
+ def on_connect(self, client: object, userdata: Any, flags: int, rc: int) -> None:
40
+ super().on_connect(client, userdata, flags, rc)
41
+ if rc == 0:
42
+ self.subscribe(self.watermeter_topic)
43
+
44
+ @override
45
+ def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
46
+ if msg.topic == self.watermeter_topic:
47
+ em = json.loads(msg.payload.decode())
48
+ self.record(em)
49
+ else:
50
+ super().on_message(client, userdata, msg)
51
+
52
+ def record(self, info: dict[str, float]) -> None:
53
+ """Writes system info to the time series database
54
+
55
+ Args:
56
+ ts (float): utc time
57
+ em (dict): water meter message
58
+ """
59
+
60
+ try:
61
+ if "leak_suspected" in info:
62
+ self.write_point(
63
+ "watermeter",
64
+ {"sensor": info["sensor"], "location": info["location"]},
65
+ {
66
+ "leak_suspected": info["leak_suspected"],
67
+ "ts": info["ts"],
68
+ },
69
+ epoc2utc(info["ts"]),
70
+ )
71
+ elif "active_lpm" in info:
72
+ self.write_point(
73
+ "watermeter",
74
+ {"sensor": info["sensor"], "location": info["location"]},
75
+ {
76
+ "total_liter": info["total_liter"],
77
+ "active_lpm": info["active_lpm"],
78
+ "ts": info["ts"],
79
+ },
80
+ epoc2utc(info["ts"]),
81
+ )
82
+ except Exception as e:
83
+ self.error(f"Writing memory to influx failed {str(e)}")
84
+
85
+ @override
86
+ def to_dict(self) -> dict[str, Any]:
87
+ data = super().to_dict() # Call super class method
88
+ watermeter_data = {}
89
+ for attr in self._WATERMETER_ATTRS:
90
+ watermeter_data[attr] = getattr(self, attr)
91
+ data[self._WATERMETER] = watermeter_data
92
+ return data
93
+
94
+ @override
95
+ def from_dict(self, data: dict[str, Any]) -> None:
96
+ super().from_dict(data) # Call super class method
97
+ if self._WATERMETER in data:
98
+ watermeter_data = data[self._WATERMETER]
99
+ for attr in self._WATERMETER_ATTRS:
100
+ setattr(self, attr, watermeter_data.get(attr, None))
@@ -0,0 +1,174 @@
1
+ """Web camera with basic image processing features.
2
+
3
+ """
4
+
5
+ import cv2
6
+ import numpy as np
7
+ from typing import Any, Optional
8
+ from typing_extensions import override
9
+ from masterpiece.mqtt import Mqtt
10
+ from masterpiece import MasterPieceThread
11
+ from juham_core import JuhamThread
12
+ from juham_core.timeutils import timestamp
13
+
14
+
15
+ class WebCameraThread(MasterPieceThread):
16
+ """Asynchronous thread for capturing and processing images of web camera."""
17
+
18
+ # class attributes
19
+ _interval: float = 60 # seconds
20
+ _location = "unknown"
21
+ _camera: int = 0 # e.g. 0 built-in, 1 external camera
22
+ _expected_image_size: int = 640 * 480
23
+
24
+ def __init__(self, client: Optional[Mqtt] = None):
25
+ """Construct with the given mqtt client. Initializes minimal 1x1 image
26
+ with timestamp 0.0, which are updated to actual image size and timestamp
27
+ with each captured image, for sub classes to process.
28
+
29
+ Args:
30
+ client (object, optional): MQTT client. Defaults to None.
31
+ """
32
+ super().__init__(client)
33
+ self.mqtt_client: Optional[Mqtt] = client
34
+ self.image: np.ndarray = np.zeros((1, 1, 3), dtype=np.uint8)
35
+ self.image_timestamp: float = 0.0
36
+
37
+ def init(
38
+ self,
39
+ interval: float,
40
+ location: str,
41
+ camera: int,
42
+ ) -> None:
43
+ """Initialize the data acquisition thread
44
+
45
+ Args:
46
+ interval (float): update interval in seconds
47
+ location (str): geographic location
48
+ camera(int) : ordinal specifying the camera to be used (0, 1)
49
+
50
+ """
51
+ self._interval = interval
52
+ self._location = location
53
+ self._camera = camera
54
+
55
+ def capture_image(self) -> np.ndarray:
56
+ """
57
+ Captures an image from the webcam and returns the image as a numpy array.
58
+
59
+ Returns:
60
+ np.ndarray: The captured image in the form of a NumPy array.
61
+ """
62
+ # Initialize the webcam (0 for the built-in camera or 1 for the USB webcam)
63
+ cap = cv2.VideoCapture(self._camera)
64
+
65
+ # Check if the webcam is opened correctly
66
+ if not cap.isOpened():
67
+ print("CANNOT ACCESS THE CAMERA")
68
+ self.error(f"Could not access the camera {self._camera}.")
69
+ return np.zeros(
70
+ (1, 1, 3), dtype=np.uint8
71
+ ) # Return an empty array if the camera isn't accessible
72
+
73
+ # Capture a frame
74
+ try:
75
+ ret, frame = cap.read()
76
+
77
+ if not ret:
78
+ print("WTF, Camera failed again")
79
+ self.error("Could not capture image.")
80
+ frame = np.zeros(
81
+ (1, 1, 3), dtype=np.uint8
82
+ ) # Return 1x1 array if capture failed
83
+ finally:
84
+ cap.release()
85
+ print("Camera released")
86
+ return frame # Return the captured image
87
+
88
+ def process_image(self, image: np.ndarray) -> np.ndarray:
89
+ """
90
+ Processes the captured image by converting it to grayscale and applying thresholding.
91
+
92
+ Args:
93
+ image (np.ndarray): The input image to process.
94
+
95
+ Returns:
96
+ np.ndarray: The processed image after grayscale conversion and thresholding.
97
+ """
98
+ if image.size < self._expected_image_size: # Check if the image is empty
99
+ self.error("Received an empty image for processing.")
100
+ return image
101
+
102
+ # Convert the image to grayscale
103
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
104
+ return gray
105
+
106
+ def enhance_contrast(self, image: np.ndarray) -> np.ndarray:
107
+ """
108
+ Enhances the contrast of a grayscale image while suppressing noise.
109
+
110
+ Args:
111
+ image (np.ndarray): The input grayscale image.
112
+
113
+ Returns:
114
+ np.ndarray: The contrast-enhanced image with reduced noise amplification.
115
+ """
116
+ if image.ndim != 2: # Ensure the image is grayscale
117
+ self.error("Contrast enhancement requires a grayscale image.")
118
+ return image
119
+
120
+ # Apply a slight Gaussian blur to reduce noise before enhancing contrast
121
+ blurred = cv2.GaussianBlur(image, (3, 3), 0)
122
+
123
+ # Apply CLAHE with conservative parameters to avoid over-enhancement
124
+ clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
125
+ contrast_enhanced = clahe.apply(blurred)
126
+
127
+ return contrast_enhanced
128
+
129
+ @override
130
+ def update_interval(self) -> float:
131
+ return self._interval
132
+
133
+ @override
134
+ def update(self) -> bool:
135
+ self.image = self.capture_image()
136
+ self.image_timestamp = timestamp()
137
+ return self.image.size == self._expected_image_size
138
+
139
+
140
+ class WebCamera(JuhamThread):
141
+ """Base class for web camera."""
142
+
143
+ _WEBCAMERA: str = "webcamera"
144
+ _WEBCAMERA_ATTRS: list[str] = ["location", "camera"]
145
+
146
+ _workerThreadId: str = WebCameraThread.get_class_id()
147
+ update_interval: float = 60
148
+ location = "home"
149
+ camera: int = 0
150
+
151
+ def __init__(self, name="webcam") -> None:
152
+ """Constructs system status automation object for acquiring and publishing
153
+ system info e.g. available memory and CPU loads.
154
+
155
+ Args:
156
+ name (str, optional): name of the object.
157
+ """
158
+ super().__init__(name)
159
+ self.worker: Optional[WebCameraThread] = None
160
+
161
+ def to_dict(self) -> dict[str, Any]:
162
+ data = super().to_dict() # Call parent class method
163
+ webcam_data = {}
164
+ for attr in self._WEBCAMERA_ATTRS:
165
+ webcam_data[attr] = getattr(self, attr)
166
+ data[self._WEBCAMERA] = webcam_data
167
+ return data
168
+
169
+ def from_dict(self, data: dict[str, Any]) -> None:
170
+ super().from_dict(data) # Call parent class method
171
+ if self._WEBCAMERA in data:
172
+ webcam_data = data[self._WEBCAMERA]
173
+ for attr in self._WEBCAMERA_ATTRS:
174
+ setattr(self, attr, webcam_data.get(attr, None))
@@ -0,0 +1,22 @@
1
+ MIT License
2
+ ===========
3
+
4
+ Copyright (c) 2024, Juha Meskanen
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.2
2
+ Name: juham-watermeter
3
+ Version: 0.0.4
4
+ Summary: Built-in Test plugin
5
+ Author-email: J Meskanen <juham.api@gmail.com>
6
+ Maintainer-email: "J. Meskanen" <juham.api@gmail.com>
7
+ Project-URL: Homepage, https://meskanen.com
8
+ Project-URL: Bug Reports, https://meskanen.com
9
+ Project-URL: Funding, https://meskanen.com
10
+ Project-URL: Say Thanks!, http://meskanen.com
11
+ Project-URL: Source, https://meskanen.com
12
+ Keywords: object-oriented,plugin,framework,watermeter,home automation
13
+ Classifier: Development Status :: 2 - Pre-Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Topic :: Software Development
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3.8
18
+ Requires-Python: >=3.8
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE.rst
21
+ Requires-Dist: juham-automation>=0.0.2
22
+ Requires-Dist: opencv-python-headless>=4.10.0
23
+ Requires-Dist: numpy
24
+ Requires-Dist: pytesseract>=0.3.13
25
+ Requires-Dist: Pillow>=10.4.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: check-manifest; extra == "dev"
@@ -0,0 +1,13 @@
1
+ juham_watermeter/__init__.py,sha256=kFVTpeSgFaXsJJ-uxxYf656Os-zVVVriEx5yAjY1XhE,412
2
+ juham_watermeter/leakdetector.py,sha256=bJV7Pfy4WDbcaMsxMyaoEkM1Z_pOMIeuyPOL4L5fib8,6539
3
+ juham_watermeter/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
4
+ juham_watermeter/watermeter_imgdiff.py,sha256=voLqypb11uOt4N_vNyENSypsplcPQE61b1yinYZYLDI,12534
5
+ juham_watermeter/watermeter_ocr.py,sha256=cicU40snCt5KU5Kvrd17DS5hLRMX2mhV-P0xwm9XXy0,9378
6
+ juham_watermeter/watermeter_ts.py,sha256=t5EOc2KFyZZdLECfD1gfNIjmlpEzd0TG92k-LacXRpU,3346
7
+ juham_watermeter/webcamera.py,sha256=GLXmB9VRjYTiQUCR8tIkHS4RgzQFoS8H2rteTtpLJdE,6066
8
+ juham_watermeter-0.0.4.dist-info/LICENSE.rst,sha256=P7_vqs6bCezlTgOp6YsAiFOc71hUF10u4zP5ohL8h5s,1096
9
+ juham_watermeter-0.0.4.dist-info/METADATA,sha256=wXiIyD_J4zrUC4m7XXnXbqHJr_YesnmxNTyf3MM6rTU,1071
10
+ juham_watermeter-0.0.4.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
11
+ juham_watermeter-0.0.4.dist-info/entry_points.txt,sha256=4KQZ-XUjd026JX-9dLAvw6e5XE6kdshWYE4pqDgeYtE,252
12
+ juham_watermeter-0.0.4.dist-info/top_level.txt,sha256=kvbS5ION4k6uJwlJuWRNHIKPrOvpVldiWeNLWbqH2X0,17
13
+ juham_watermeter-0.0.4.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.8.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,6 @@
1
+ [juham.plugins]
2
+ imgdiff_plugin = juham_watermeter:WaterMeterImgDiff
3
+ leakdetector_plugin = juham_watermeter:LeakDetector
4
+ ocr_plugin = juham_watermeter:WaterMeterOCR
5
+ ts_plugin = juham_watermeter:WaterMeterTs
6
+ webcamera_plugin = juham_watermeter:WebCamera
@@ -0,0 +1 @@
1
+ juham_watermeter