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.
- juham_watermeter/__init__.py +21 -0
- juham_watermeter/leakdetector.py +170 -0
- juham_watermeter/py.typed +1 -0
- juham_watermeter/watermeter_imgdiff.py +328 -0
- juham_watermeter/watermeter_ocr.py +273 -0
- juham_watermeter/watermeter_ts.py +100 -0
- juham_watermeter/webcamera.py +174 -0
- juham_watermeter-0.0.4.dist-info/LICENSE.rst +22 -0
- juham_watermeter-0.0.4.dist-info/METADATA +27 -0
- juham_watermeter-0.0.4.dist-info/RECORD +13 -0
- juham_watermeter-0.0.4.dist-info/WHEEL +5 -0
- juham_watermeter-0.0.4.dist-info/entry_points.txt +6 -0
- juham_watermeter-0.0.4.dist-info/top_level.txt +1 -0
@@ -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 @@
|
|
1
|
+
juham_watermeter
|