hiktemp 0.1.0__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.
- hiktemp/__init__.py +76 -0
- hiktemp/_fetch.py +81 -0
- hiktemp/_frame.py +120 -0
- hiktemp-0.1.0.dist-info/METADATA +82 -0
- hiktemp-0.1.0.dist-info/RECORD +7 -0
- hiktemp-0.1.0.dist-info/WHEEL +5 -0
- hiktemp-0.1.0.dist-info/top_level.txt +1 -0
hiktemp/__init__.py
ADDED
|
@@ -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)
|
hiktemp/_fetch.py
ADDED
|
@@ -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
|
hiktemp/_frame.py
ADDED
|
@@ -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,7 @@
|
|
|
1
|
+
hiktemp/__init__.py,sha256=8K3loa7DGTDE1CbbpOWRQrQbNHvWuW4rmFsRZdgbnqA,2256
|
|
2
|
+
hiktemp/_fetch.py,sha256=nsOg1pVnstzoDvL1_df4TTaTEgHalBLvwqn_ZxrY5-s,2032
|
|
3
|
+
hiktemp/_frame.py,sha256=Itl1zPBkO5D126WJQ4AtdqpY27e8GWnWy7TzU2nNES8,3987
|
|
4
|
+
hiktemp-0.1.0.dist-info/METADATA,sha256=my-9_-eNT9LwoKE7FrCnIfBeJbl2kFUYwBcMh7RFFw4,2309
|
|
5
|
+
hiktemp-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
6
|
+
hiktemp-0.1.0.dist-info/top_level.txt,sha256=0qqqqDgHhms1RsQjvCHnsnU2pIoFuI9bDJYNHiOapu4,8
|
|
7
|
+
hiktemp-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hiktemp
|