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 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)
@@ -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,6 @@
1
+ requests>=2.28
2
+ numpy>=1.23
3
+
4
+ [dev]
5
+ pytest
6
+ ruff
@@ -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*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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