hiktemp 0.1.0__tar.gz
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.
- hiktemp-0.1.0/PKG-INFO +82 -0
- hiktemp-0.1.0/README.md +69 -0
- hiktemp-0.1.0/hiktemp/__init__.py +76 -0
- hiktemp-0.1.0/hiktemp/_fetch.py +81 -0
- hiktemp-0.1.0/hiktemp/_frame.py +120 -0
- hiktemp-0.1.0/hiktemp.egg-info/PKG-INFO +82 -0
- hiktemp-0.1.0/hiktemp.egg-info/SOURCES.txt +11 -0
- hiktemp-0.1.0/hiktemp.egg-info/dependency_links.txt +1 -0
- hiktemp-0.1.0/hiktemp.egg-info/requires.txt +6 -0
- hiktemp-0.1.0/hiktemp.egg-info/top_level.txt +1 -0
- hiktemp-0.1.0/pyproject.toml +19 -0
- hiktemp-0.1.0/setup.cfg +4 -0
- hiktemp-0.1.0/tests/test_hiktemp.py +156 -0
hiktemp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hiktemp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Minimal radiometric temperature extraction from Hikvision thermal cameras
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: requests>=2.28
|
|
9
|
+
Requires-Dist: numpy>=1.23
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest; extra == "dev"
|
|
12
|
+
Requires-Dist: ruff; extra == "dev"
|
|
13
|
+
|
|
14
|
+
# hiktemp
|
|
15
|
+
|
|
16
|
+
Minimal radiometric temperature extraction from Hikvision thermal cameras via ISAPI.
|
|
17
|
+
No SDK required. Only depends on `requests` and `numpy`.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install hiktemp
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or from source:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
git clone https://github.com/yourname/hiktemp
|
|
29
|
+
cd hiktemp
|
|
30
|
+
pip install -e .
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from hiktemp import hiktemp
|
|
37
|
+
|
|
38
|
+
# pull one frame
|
|
39
|
+
frame = hiktemp("http://192.168.1.1", "admin", "password")
|
|
40
|
+
|
|
41
|
+
frame.matrix # np.ndarray (H, W) float32 — per-pixel °C
|
|
42
|
+
frame.jpeg # bytes — raw JPEG from camera (AGC, display only)
|
|
43
|
+
frame.meta # dict — camera descriptor
|
|
44
|
+
frame.min # float — global min °C
|
|
45
|
+
frame.max # float — global max °C
|
|
46
|
+
frame.mean # float — global mean °C
|
|
47
|
+
frame.std # float — global std °C
|
|
48
|
+
frame.hotspot() # (row, col) — global hotspot
|
|
49
|
+
frame.coldspot() # (row, col) — global coldspot
|
|
50
|
+
frame.to_bgr() # np.ndarray (H,W,3) uint8 — cv2.imshow() ready
|
|
51
|
+
|
|
52
|
+
# band filter — lo/hi passed at call time
|
|
53
|
+
frame.hotspot(lo=28, hi=29) # hotspot within band
|
|
54
|
+
frame.coldspot(lo=28, hi=29) # coldspot within band
|
|
55
|
+
frame.masked(lo=28, hi=29) # matrix, out-of-band pixels = NaN
|
|
56
|
+
frame.alpha(lo=28, hi=29) # float32 (H,W) quintic smoothstep mask
|
|
57
|
+
frame.to_rgba(lo=28, hi=29) # (H,W,4) uint8 with alpha mask applied
|
|
58
|
+
|
|
59
|
+
# reuse session for continuous polling
|
|
60
|
+
import requests
|
|
61
|
+
from requests.auth import HTTPDigestAuth
|
|
62
|
+
|
|
63
|
+
session = requests.Session()
|
|
64
|
+
session.auth = HTTPDigestAuth("admin", "password")
|
|
65
|
+
|
|
66
|
+
while True:
|
|
67
|
+
frame = hiktemp("http://192.168.1.1", "admin", "password", session=session)
|
|
68
|
+
bgr = frame.to_bgr()
|
|
69
|
+
# cv2.imshow("thermal", bgr)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Requirements
|
|
73
|
+
|
|
74
|
+
- Python >= 3.10
|
|
75
|
+
- `requests >= 2.28`
|
|
76
|
+
- `numpy >= 1.23`
|
|
77
|
+
|
|
78
|
+
opencv-python is **optional** — `to_bgr()` and `to_rgba()` return plain numpy arrays.
|
|
79
|
+
|
|
80
|
+
## Tested on
|
|
81
|
+
|
|
82
|
+
- DS-2TD2138-10/QY (384×288, float32 °C, channel 1)
|
hiktemp-0.1.0/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# hiktemp
|
|
2
|
+
|
|
3
|
+
Minimal radiometric temperature extraction from Hikvision thermal cameras via ISAPI.
|
|
4
|
+
No SDK required. Only depends on `requests` and `numpy`.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install hiktemp
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Or from source:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
git clone https://github.com/yourname/hiktemp
|
|
16
|
+
cd hiktemp
|
|
17
|
+
pip install -e .
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from hiktemp import hiktemp
|
|
24
|
+
|
|
25
|
+
# pull one frame
|
|
26
|
+
frame = hiktemp("http://192.168.1.1", "admin", "password")
|
|
27
|
+
|
|
28
|
+
frame.matrix # np.ndarray (H, W) float32 — per-pixel °C
|
|
29
|
+
frame.jpeg # bytes — raw JPEG from camera (AGC, display only)
|
|
30
|
+
frame.meta # dict — camera descriptor
|
|
31
|
+
frame.min # float — global min °C
|
|
32
|
+
frame.max # float — global max °C
|
|
33
|
+
frame.mean # float — global mean °C
|
|
34
|
+
frame.std # float — global std °C
|
|
35
|
+
frame.hotspot() # (row, col) — global hotspot
|
|
36
|
+
frame.coldspot() # (row, col) — global coldspot
|
|
37
|
+
frame.to_bgr() # np.ndarray (H,W,3) uint8 — cv2.imshow() ready
|
|
38
|
+
|
|
39
|
+
# band filter — lo/hi passed at call time
|
|
40
|
+
frame.hotspot(lo=28, hi=29) # hotspot within band
|
|
41
|
+
frame.coldspot(lo=28, hi=29) # coldspot within band
|
|
42
|
+
frame.masked(lo=28, hi=29) # matrix, out-of-band pixels = NaN
|
|
43
|
+
frame.alpha(lo=28, hi=29) # float32 (H,W) quintic smoothstep mask
|
|
44
|
+
frame.to_rgba(lo=28, hi=29) # (H,W,4) uint8 with alpha mask applied
|
|
45
|
+
|
|
46
|
+
# reuse session for continuous polling
|
|
47
|
+
import requests
|
|
48
|
+
from requests.auth import HTTPDigestAuth
|
|
49
|
+
|
|
50
|
+
session = requests.Session()
|
|
51
|
+
session.auth = HTTPDigestAuth("admin", "password")
|
|
52
|
+
|
|
53
|
+
while True:
|
|
54
|
+
frame = hiktemp("http://192.168.1.1", "admin", "password", session=session)
|
|
55
|
+
bgr = frame.to_bgr()
|
|
56
|
+
# cv2.imshow("thermal", bgr)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Requirements
|
|
60
|
+
|
|
61
|
+
- Python >= 3.10
|
|
62
|
+
- `requests >= 2.28`
|
|
63
|
+
- `numpy >= 1.23`
|
|
64
|
+
|
|
65
|
+
opencv-python is **optional** — `to_bgr()` and `to_rgba()` return plain numpy arrays.
|
|
66
|
+
|
|
67
|
+
## Tested on
|
|
68
|
+
|
|
69
|
+
- DS-2TD2138-10/QY (384×288, float32 °C, channel 1)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
hiktemp
|
|
3
|
+
~~~~~~~
|
|
4
|
+
Minimal radiometric temperature extraction from Hikvision thermal cameras.
|
|
5
|
+
|
|
6
|
+
>>> from hiktemp import hiktemp
|
|
7
|
+
>>> from hiktemp import hiktemp
|
|
8
|
+
>>> frame = hiktemp("http://192.168.1.1", "admin", "password")
|
|
9
|
+
>>> frame.matrix # np.ndarray (H,W) float32 °C
|
|
10
|
+
>>> frame.hotspot() # (row, col) global
|
|
11
|
+
>>> frame.hotspot(lo=28, hi=29) # (row, col) within band
|
|
12
|
+
>>> frame.masked(lo=28, hi=29) # NaN outside band
|
|
13
|
+
|
|
14
|
+
Only requires: requests, numpy.
|
|
15
|
+
Visualization (colormap, bgr, rgba) is left to the caller via matplotlib/cv2.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import requests
|
|
21
|
+
|
|
22
|
+
from ._fetch import fetch
|
|
23
|
+
from ._frame import ThermalFrame
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
__all__ = ["hiktemp", "ThermalFrame"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def hiktemp(
|
|
30
|
+
url: str,
|
|
31
|
+
username: str,
|
|
32
|
+
password: str,
|
|
33
|
+
*,
|
|
34
|
+
channel: int = 1,
|
|
35
|
+
timeout: float = 10.0,
|
|
36
|
+
session: requests.Session | None = None,
|
|
37
|
+
) -> ThermalFrame:
|
|
38
|
+
"""
|
|
39
|
+
Pull one radiometric frame from a Hikvision thermal camera.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
url : str
|
|
44
|
+
Base URL of the camera, e.g. ``"http://192.168.1.1"``.
|
|
45
|
+
username : str
|
|
46
|
+
Digest-auth username.
|
|
47
|
+
password : str
|
|
48
|
+
Digest-auth password.
|
|
49
|
+
channel : int
|
|
50
|
+
ISAPI thermal channel, default 1.
|
|
51
|
+
timeout : float
|
|
52
|
+
HTTP request timeout in seconds.
|
|
53
|
+
session : requests.Session, optional
|
|
54
|
+
Reuse an existing session (avoids repeated digest handshakes).
|
|
55
|
+
|
|
56
|
+
Returns
|
|
57
|
+
-------
|
|
58
|
+
ThermalFrame
|
|
59
|
+
``.matrix`` — raw float32 °C array (H, W)
|
|
60
|
+
``.jpeg`` — raw JPEG bytes (AGC, display only)
|
|
61
|
+
``.meta`` — camera descriptor dict
|
|
62
|
+
``.min/max/mean/std`` — scalar stats over full matrix
|
|
63
|
+
``.hotspot(lo, hi)`` — (row, col) of peak temp, optional band
|
|
64
|
+
``.coldspot(lo, hi)`` — (row, col) of min temp, optional band
|
|
65
|
+
``.masked(lo, hi)`` — matrix with out-of-band pixels = NaN
|
|
66
|
+
``.alpha(lo, hi)`` — float32 (H,W) quintic smoothstep mask
|
|
67
|
+
"""
|
|
68
|
+
meta, jpeg, matrix = fetch(
|
|
69
|
+
url,
|
|
70
|
+
username,
|
|
71
|
+
password,
|
|
72
|
+
channel=channel,
|
|
73
|
+
timeout=timeout,
|
|
74
|
+
session=session,
|
|
75
|
+
)
|
|
76
|
+
return ThermalFrame(matrix, jpeg, meta)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
hiktemp._fetch
|
|
3
|
+
~~~~~~~~~~~~~~
|
|
4
|
+
HTTP layer: digest auth, multipart parse, float32 blob decode.
|
|
5
|
+
Only depends on `requests` and `numpy`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import requests
|
|
12
|
+
from requests.auth import HTTPDigestAuth
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
_ENDPOINT = "/ISAPI/Thermal/channels/{ch}/thermometry/jpegPicWithAppendData?format=json"
|
|
17
|
+
_BOUNDARY = b"--boundary"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _body(part: bytes) -> bytes:
|
|
21
|
+
"""Strip multipart headers, return body bytes."""
|
|
22
|
+
i = part.find(b"\r\n\r\n")
|
|
23
|
+
return part[i + 4 :]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def fetch(
|
|
27
|
+
url: str,
|
|
28
|
+
username: str,
|
|
29
|
+
password: str,
|
|
30
|
+
channel: int = 1,
|
|
31
|
+
timeout: float = 10.0,
|
|
32
|
+
session: requests.Session | None = None,
|
|
33
|
+
) -> tuple[dict, bytes, np.ndarray]:
|
|
34
|
+
"""
|
|
35
|
+
Pull one thermal frame from a Hikvision ISAPI endpoint.
|
|
36
|
+
|
|
37
|
+
Returns
|
|
38
|
+
-------
|
|
39
|
+
meta : dict
|
|
40
|
+
Parsed JSON descriptor from Part 1.
|
|
41
|
+
jpeg : bytes
|
|
42
|
+
Raw JPEG bytes from Part 2.
|
|
43
|
+
matrix : np.ndarray shape (H, W) dtype float32
|
|
44
|
+
Per-pixel temperature in °C from Part 3.
|
|
45
|
+
"""
|
|
46
|
+
own_session = session is None
|
|
47
|
+
if own_session:
|
|
48
|
+
session = requests.Session()
|
|
49
|
+
session.auth = HTTPDigestAuth(username, password)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
resp = session.get(
|
|
53
|
+
url.rstrip("/") + _ENDPOINT.format(ch=channel),
|
|
54
|
+
stream=True,
|
|
55
|
+
timeout=timeout,
|
|
56
|
+
)
|
|
57
|
+
resp.raise_for_status()
|
|
58
|
+
finally:
|
|
59
|
+
if own_session:
|
|
60
|
+
session.close()
|
|
61
|
+
|
|
62
|
+
raw = resp.content
|
|
63
|
+
parts = raw.split(_BOUNDARY)
|
|
64
|
+
|
|
65
|
+
# Part 1 — JSON metadata
|
|
66
|
+
meta_body = _body(parts[1])
|
|
67
|
+
meta_body = meta_body[: meta_body.find(b"--")]
|
|
68
|
+
meta = json.loads(meta_body)["JpegPictureWithAppendData"]
|
|
69
|
+
|
|
70
|
+
W: int = meta["jpegPicWidth"]
|
|
71
|
+
H: int = meta["jpegPicHeight"]
|
|
72
|
+
|
|
73
|
+
# Part 2 — thermal JPEG
|
|
74
|
+
jpeg = _body(parts[2])
|
|
75
|
+
jpeg = jpeg[: jpeg.find(_BOUNDARY)]
|
|
76
|
+
|
|
77
|
+
# Part 3 — float32 temperature blob
|
|
78
|
+
blob = _body(parts[3])[: W * H * 4]
|
|
79
|
+
matrix = np.frombuffer(blob, dtype="<f4").reshape(H, W).copy()
|
|
80
|
+
|
|
81
|
+
return meta, jpeg, matrix
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
hiktemp._frame
|
|
3
|
+
~~~~~~~~~~~~~~
|
|
4
|
+
ThermalFrame — thin wrapper around the float32 temperature matrix.
|
|
5
|
+
No dependencies beyond numpy.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _band_alpha(matrix: np.ndarray, lo: float, hi: float) -> np.ndarray:
|
|
14
|
+
"""
|
|
15
|
+
Quintic smoothstep alpha mask for band [lo, hi].
|
|
16
|
+
1.0 inside band, fades to 0.0 outside over a 5% margin.
|
|
17
|
+
Pure polynomial — no exp, no overflow.
|
|
18
|
+
"""
|
|
19
|
+
fade = max((hi - lo) * 0.05, 0.1)
|
|
20
|
+
|
|
21
|
+
def _quintic(t: np.ndarray) -> np.ndarray:
|
|
22
|
+
t = np.clip(t, 0.0, 1.0)
|
|
23
|
+
return t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
|
|
24
|
+
|
|
25
|
+
rise = _quintic((matrix - (lo - fade)) / fade)
|
|
26
|
+
fall = _quintic(((hi + fade) - matrix) / fade)
|
|
27
|
+
return (rise * fall).astype(np.float32)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ThermalFrame:
|
|
31
|
+
"""
|
|
32
|
+
A single radiometric thermal frame. Stores only raw sensor data.
|
|
33
|
+
Band filtering is applied at query time via method arguments.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
matrix : np.ndarray (H, W) float32
|
|
38
|
+
Per-pixel temperature in °C.
|
|
39
|
+
jpeg : bytes
|
|
40
|
+
Raw JPEG from the camera (AGC, display only).
|
|
41
|
+
meta : dict
|
|
42
|
+
JSON descriptor returned by the camera.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
__slots__ = ("matrix", "jpeg", "meta")
|
|
46
|
+
|
|
47
|
+
def __init__(self, matrix: np.ndarray, jpeg: bytes, meta: dict) -> None:
|
|
48
|
+
self.matrix = matrix
|
|
49
|
+
self.jpeg = jpeg
|
|
50
|
+
self.meta = meta
|
|
51
|
+
|
|
52
|
+
# ── stats ──────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def min(self) -> float:
|
|
56
|
+
return float(self.matrix.min())
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def max(self) -> float:
|
|
60
|
+
return float(self.matrix.max())
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def mean(self) -> float:
|
|
64
|
+
return float(self.matrix.mean())
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def std(self) -> float:
|
|
68
|
+
return float(self.matrix.std())
|
|
69
|
+
|
|
70
|
+
def hotspot(
|
|
71
|
+
self,
|
|
72
|
+
lo: float | None = None,
|
|
73
|
+
hi: float | None = None,
|
|
74
|
+
) -> tuple[int, int]:
|
|
75
|
+
"""
|
|
76
|
+
(row, col) of maximum temperature pixel.
|
|
77
|
+
With lo/hi → restricted to pixels inside band.
|
|
78
|
+
"""
|
|
79
|
+
if lo is not None and hi is not None:
|
|
80
|
+
m = self.matrix.copy()
|
|
81
|
+
m[self.alpha(lo, hi) < 0.01] = -np.inf
|
|
82
|
+
else:
|
|
83
|
+
m = self.matrix
|
|
84
|
+
r, c = np.unravel_index(int(np.argmax(m)), m.shape)
|
|
85
|
+
return int(r), int(c)
|
|
86
|
+
|
|
87
|
+
def coldspot(
|
|
88
|
+
self,
|
|
89
|
+
lo: float | None = None,
|
|
90
|
+
hi: float | None = None,
|
|
91
|
+
) -> tuple[int, int]:
|
|
92
|
+
"""
|
|
93
|
+
(row, col) of minimum temperature pixel.
|
|
94
|
+
With lo/hi → restricted to pixels inside band.
|
|
95
|
+
"""
|
|
96
|
+
if lo is not None and hi is not None:
|
|
97
|
+
m = self.matrix.copy()
|
|
98
|
+
m[self.alpha(lo, hi) < 0.01] = np.inf
|
|
99
|
+
else:
|
|
100
|
+
m = self.matrix
|
|
101
|
+
r, c = np.unravel_index(int(np.argmin(m)), m.shape)
|
|
102
|
+
return int(r), int(c)
|
|
103
|
+
|
|
104
|
+
# ── band ───────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
def alpha(self, lo: float, hi: float) -> np.ndarray:
|
|
107
|
+
"""Float32 (H, W) quintic smoothstep mask — 1.0 inside [lo, hi], 0.0 outside."""
|
|
108
|
+
return _band_alpha(self.matrix, lo, hi)
|
|
109
|
+
|
|
110
|
+
def masked(self, lo: float, hi: float) -> np.ndarray:
|
|
111
|
+
"""Float32 (H, W) matrix with out-of-band pixels set to NaN."""
|
|
112
|
+
out = self.matrix.copy()
|
|
113
|
+
out[self.alpha(lo, hi) < 0.01] = np.nan
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
# ── dunder ─────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
def __repr__(self) -> str:
|
|
119
|
+
H, W = self.matrix.shape
|
|
120
|
+
return f"<ThermalFrame {W}×{H} min={self.min:.2f} max={self.max:.2f} °C>"
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hiktemp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Minimal radiometric temperature extraction from Hikvision thermal cameras
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: requests>=2.28
|
|
9
|
+
Requires-Dist: numpy>=1.23
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest; extra == "dev"
|
|
12
|
+
Requires-Dist: ruff; extra == "dev"
|
|
13
|
+
|
|
14
|
+
# hiktemp
|
|
15
|
+
|
|
16
|
+
Minimal radiometric temperature extraction from Hikvision thermal cameras via ISAPI.
|
|
17
|
+
No SDK required. Only depends on `requests` and `numpy`.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install hiktemp
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or from source:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
git clone https://github.com/yourname/hiktemp
|
|
29
|
+
cd hiktemp
|
|
30
|
+
pip install -e .
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from hiktemp import hiktemp
|
|
37
|
+
|
|
38
|
+
# pull one frame
|
|
39
|
+
frame = hiktemp("http://192.168.1.1", "admin", "password")
|
|
40
|
+
|
|
41
|
+
frame.matrix # np.ndarray (H, W) float32 — per-pixel °C
|
|
42
|
+
frame.jpeg # bytes — raw JPEG from camera (AGC, display only)
|
|
43
|
+
frame.meta # dict — camera descriptor
|
|
44
|
+
frame.min # float — global min °C
|
|
45
|
+
frame.max # float — global max °C
|
|
46
|
+
frame.mean # float — global mean °C
|
|
47
|
+
frame.std # float — global std °C
|
|
48
|
+
frame.hotspot() # (row, col) — global hotspot
|
|
49
|
+
frame.coldspot() # (row, col) — global coldspot
|
|
50
|
+
frame.to_bgr() # np.ndarray (H,W,3) uint8 — cv2.imshow() ready
|
|
51
|
+
|
|
52
|
+
# band filter — lo/hi passed at call time
|
|
53
|
+
frame.hotspot(lo=28, hi=29) # hotspot within band
|
|
54
|
+
frame.coldspot(lo=28, hi=29) # coldspot within band
|
|
55
|
+
frame.masked(lo=28, hi=29) # matrix, out-of-band pixels = NaN
|
|
56
|
+
frame.alpha(lo=28, hi=29) # float32 (H,W) quintic smoothstep mask
|
|
57
|
+
frame.to_rgba(lo=28, hi=29) # (H,W,4) uint8 with alpha mask applied
|
|
58
|
+
|
|
59
|
+
# reuse session for continuous polling
|
|
60
|
+
import requests
|
|
61
|
+
from requests.auth import HTTPDigestAuth
|
|
62
|
+
|
|
63
|
+
session = requests.Session()
|
|
64
|
+
session.auth = HTTPDigestAuth("admin", "password")
|
|
65
|
+
|
|
66
|
+
while True:
|
|
67
|
+
frame = hiktemp("http://192.168.1.1", "admin", "password", session=session)
|
|
68
|
+
bgr = frame.to_bgr()
|
|
69
|
+
# cv2.imshow("thermal", bgr)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Requirements
|
|
73
|
+
|
|
74
|
+
- Python >= 3.10
|
|
75
|
+
- `requests >= 2.28`
|
|
76
|
+
- `numpy >= 1.23`
|
|
77
|
+
|
|
78
|
+
opencv-python is **optional** — `to_bgr()` and `to_rgba()` return plain numpy arrays.
|
|
79
|
+
|
|
80
|
+
## Tested on
|
|
81
|
+
|
|
82
|
+
- DS-2TD2138-10/QY (384×288, float32 °C, channel 1)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
hiktemp/__init__.py
|
|
4
|
+
hiktemp/_fetch.py
|
|
5
|
+
hiktemp/_frame.py
|
|
6
|
+
hiktemp.egg-info/PKG-INFO
|
|
7
|
+
hiktemp.egg-info/SOURCES.txt
|
|
8
|
+
hiktemp.egg-info/dependency_links.txt
|
|
9
|
+
hiktemp.egg-info/requires.txt
|
|
10
|
+
hiktemp.egg-info/top_level.txt
|
|
11
|
+
tests/test_hiktemp.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hiktemp
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hiktemp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Minimal radiometric temperature extraction from Hikvision thermal cameras"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = ["requests>=2.28", "numpy>=1.23"]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
dev = ["pytest", "ruff"]
|
|
16
|
+
|
|
17
|
+
[tool.setuptools.packages.find]
|
|
18
|
+
where = ["."]
|
|
19
|
+
include = ["hiktemp*"]
|
hiktemp-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Smoke tests for hiktemp.
|
|
3
|
+
Uses debug_multipart.raw so no live camera needed.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pytest
|
|
14
|
+
from unittest.mock import MagicMock, patch
|
|
15
|
+
|
|
16
|
+
from hiktemp._fetch import _body
|
|
17
|
+
from hiktemp._frame import ThermalFrame
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── fixtures ──────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
RAW = os.path.join(os.path.dirname(__file__), "sample_frame.raw")
|
|
23
|
+
assert os.path.exists(RAW), f"test fixture missing: {RAW}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture
|
|
27
|
+
def raw_data():
|
|
28
|
+
with open(RAW, "rb") as f:
|
|
29
|
+
return f.read()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture
|
|
33
|
+
def frame(raw_data):
|
|
34
|
+
"""Build a ThermalFrame directly from cached raw file."""
|
|
35
|
+
parts = raw_data.split(b"--boundary")
|
|
36
|
+
meta_body = _body(parts[1])
|
|
37
|
+
meta = json.loads(meta_body.split(b"--")[0])["JpegPictureWithAppendData"]
|
|
38
|
+
W, H = meta["jpegPicWidth"], meta["jpegPicHeight"]
|
|
39
|
+
jpeg = _body(parts[2])
|
|
40
|
+
jpeg = jpeg[: jpeg.find(b"--boundary")]
|
|
41
|
+
blob = _body(parts[3])[: W * H * 4]
|
|
42
|
+
matrix = np.frombuffer(blob, dtype="<f4").reshape(H, W).copy()
|
|
43
|
+
return ThermalFrame(matrix, jpeg, meta)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.fixture
|
|
47
|
+
def frame_banded(raw_data):
|
|
48
|
+
parts = raw_data.split(b"--boundary")
|
|
49
|
+
meta = json.loads(_body(parts[1]).split(b"--")[0])["JpegPictureWithAppendData"]
|
|
50
|
+
W, H = meta["jpegPicWidth"], meta["jpegPicHeight"]
|
|
51
|
+
jpeg = _body(parts[2])
|
|
52
|
+
jpeg = jpeg[: jpeg.find(b"--boundary")]
|
|
53
|
+
blob = _body(parts[3])[: W * H * 4]
|
|
54
|
+
matrix = np.frombuffer(blob, dtype="<f4").reshape(H, W).copy()
|
|
55
|
+
return ThermalFrame(matrix, jpeg, meta)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ── ThermalFrame basic ────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_matrix_shape(frame):
|
|
62
|
+
assert frame.matrix.shape == (288, 384)
|
|
63
|
+
assert frame.matrix.dtype == np.float32
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_stats(frame):
|
|
67
|
+
assert frame.min <= frame.mean <= frame.max
|
|
68
|
+
assert frame.std >= 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_hotspot_in_bounds(frame):
|
|
72
|
+
r, c = frame.hotspot()
|
|
73
|
+
H, W = frame.matrix.shape
|
|
74
|
+
assert 0 <= r < H and 0 <= c < W
|
|
75
|
+
assert frame.matrix[r, c] == pytest.approx(frame.matrix.max())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_coldspot_in_bounds(frame):
|
|
79
|
+
r, c = frame.coldspot()
|
|
80
|
+
assert frame.matrix[r, c] == pytest.approx(frame.matrix.min())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_repr(frame):
|
|
84
|
+
assert "ThermalFrame" in repr(frame)
|
|
85
|
+
assert "384" in repr(frame)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ── band masking ──────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_alpha_shape(frame_banded):
|
|
92
|
+
a = frame_banded.alpha(lo=28.0, hi=29.0)
|
|
93
|
+
assert a.shape == frame_banded.matrix.shape
|
|
94
|
+
assert a.dtype == np.float32
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_alpha_range(frame_banded):
|
|
98
|
+
a = frame_banded.alpha(lo=28.0, hi=29.0)
|
|
99
|
+
assert a.min() >= 0.0
|
|
100
|
+
assert a.max() <= 1.0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_masked_outside_is_nan(frame_banded):
|
|
104
|
+
m = frame_banded.masked(lo=28.0, hi=29.0)
|
|
105
|
+
far_outside = frame_banded.matrix < 27.5
|
|
106
|
+
if far_outside.any():
|
|
107
|
+
assert np.all(np.isnan(m[far_outside]))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_masked_inside_not_nan(frame_banded):
|
|
111
|
+
m = frame_banded.masked(lo=28.0, hi=29.0)
|
|
112
|
+
mid = (frame_banded.matrix >= 28.2) & (frame_banded.matrix <= 28.8)
|
|
113
|
+
if mid.any():
|
|
114
|
+
assert not np.any(np.isnan(m[mid]))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_no_band_full_matrix(frame):
|
|
118
|
+
# no band — hotspot/coldspot use full matrix
|
|
119
|
+
r, c = frame.hotspot()
|
|
120
|
+
assert frame.matrix[r, c] == pytest.approx(frame.matrix.max())
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_hotspot_in_band(frame_banded):
|
|
124
|
+
r, c = frame_banded.hotspot(lo=28.0, hi=29.0)
|
|
125
|
+
assert 28.0 - 0.1 <= frame_banded.matrix[r, c] <= 29.0 + 0.1
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_coldspot_in_band(frame_banded):
|
|
129
|
+
r, c = frame_banded.coldspot(lo=28.0, hi=29.0)
|
|
130
|
+
assert 28.0 - 0.1 <= frame_banded.matrix[r, c] <= 29.0 + 0.1
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ── hiktemp() integration (mocked HTTP) ──────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_hiktemp_integration(raw_data):
|
|
138
|
+
mock_resp = MagicMock()
|
|
139
|
+
mock_resp.content = raw_data
|
|
140
|
+
mock_resp.raise_for_status = MagicMock()
|
|
141
|
+
|
|
142
|
+
with patch("hiktemp._fetch.requests.Session") as MockSession:
|
|
143
|
+
MockSession.return_value.__enter__ = MagicMock()
|
|
144
|
+
instance = MockSession.return_value
|
|
145
|
+
instance.get.return_value = mock_resp
|
|
146
|
+
|
|
147
|
+
# call fetch directly with the mock session
|
|
148
|
+
from hiktemp._fetch import fetch
|
|
149
|
+
|
|
150
|
+
with patch("hiktemp._fetch.requests.Session", return_value=instance):
|
|
151
|
+
meta, jpeg, matrix = fetch("http://fake", "admin", "pass", session=instance)
|
|
152
|
+
|
|
153
|
+
assert matrix.shape == (288, 384)
|
|
154
|
+
assert matrix.dtype == np.float32
|
|
155
|
+
assert matrix.min() > -21
|
|
156
|
+
assert matrix.max() < 151
|