immdebug 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.
immdebug/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """immdebug: send images to the immdebug viewer for visual debugging.
2
+
3
+ Usage:
4
+ import numpy as np
5
+ from immdebug import immdebug
6
+
7
+ image = np.random.randint(0, 255, (480, 640, 3), dtype=np.uint8)
8
+ immdebug(image, "my image")
9
+
10
+ The viewer can be launched with:
11
+ python -m immdebug.viewer
12
+ """
13
+
14
+ from immdebug.client import immdebug
15
+
16
+ __all__ = ["immdebug"]
immdebug/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running the viewer with: python -m immdebug"""
2
+
3
+ from immdebug.viewer import main
4
+
5
+ main()
immdebug/client.py ADDED
@@ -0,0 +1,127 @@
1
+ """Pure Python client for immdebug: send images to the immdebug_viewer app.
2
+
3
+ Usage:
4
+ import numpy as np
5
+ from immdebug import immdebug
6
+
7
+ image = np.random.randint(0, 255, (480, 640, 3), dtype=np.uint8)
8
+ immdebug(image, "my image")
9
+
10
+ The immdebug_viewer must be running separately. It polls a temp directory
11
+ for incoming image payloads.
12
+ """
13
+
14
+ import os
15
+ import struct
16
+ import tempfile
17
+ import time
18
+ import random
19
+ from pathlib import Path
20
+ from typing import Tuple
21
+
22
+ import numpy as np
23
+
24
+
25
+ # OpenCV depth constants: cv::Mat::depth()
26
+ _CV_DEPTH = {
27
+ np.dtype("uint8"): 0,
28
+ np.dtype("int8"): 1,
29
+ np.dtype("uint16"): 2,
30
+ np.dtype("int16"): 3,
31
+ np.dtype("int32"): 4,
32
+ np.dtype("float32"): 5,
33
+ np.dtype("float64"): 6,
34
+ }
35
+
36
+ # Random base ID, incremented per call (mirrors C++ static id)
37
+ _next_id = random.randint(100_000_000_000, 1_000_000_000_000)
38
+
39
+
40
+ def _immdebug_folder(while_writing: bool) -> Path:
41
+ base = Path(tempfile.gettempdir()) / "ImmDebug"
42
+ folder = base / "Writing" if while_writing else base
43
+ folder.mkdir(parents=True, exist_ok=True)
44
+ return folder
45
+
46
+
47
+ def _make_unique_filename(while_writing: bool) -> Path:
48
+ global _next_id
49
+ _next_id += 1
50
+ return _immdebug_folder(while_writing) / f"{_next_id}.bindata"
51
+
52
+
53
+ def _remove_old_images(max_age_seconds: float = 3600.0) -> None:
54
+ folder = _immdebug_folder(False)
55
+ now = time.time()
56
+ for entry in folder.rglob("*"):
57
+ if not entry.is_file():
58
+ continue
59
+ try:
60
+ if now - entry.stat().st_mtime > max_age_seconds:
61
+ entry.unlink()
62
+ except OSError:
63
+ pass
64
+
65
+
66
+ def _write_string(buf: bytearray, s: str) -> None:
67
+ encoded = s.encode("utf-8")
68
+ buf += struct.pack("=Q", len(encoded)) # size_t (8 bytes on 64-bit)
69
+ buf += encoded
70
+
71
+
72
+ def _write_mat(buf: bytearray, image: np.ndarray) -> None:
73
+ image = np.ascontiguousarray(image)
74
+ rows, cols = image.shape[0], image.shape[1]
75
+ channels = 1 if image.ndim == 2 else image.shape[2]
76
+
77
+ depth = _CV_DEPTH.get(image.dtype)
78
+ if depth is None:
79
+ raise ValueError(f"Unsupported dtype: {image.dtype}. Supported: {list(_CV_DEPTH.keys())}")
80
+
81
+ elem_type = depth + (channels - 1) * 8
82
+ elem_size = image.dtype.itemsize * channels
83
+
84
+ # Match C++ WriteValue<int>, WriteValue<size_t>, WriteValue<int>
85
+ # "=" = native byte order, no padding; "Q" = uint64 for size_t
86
+ buf += struct.pack("=iiQi", cols, rows, elem_size, elem_type)
87
+ buf += image.tobytes()
88
+
89
+
90
+ def immdebug(
91
+ image: np.ndarray,
92
+ legend: str = "",
93
+ zoom_center: Tuple[float, float] = (0.0, 0.0),
94
+ zoom_ratio: float = -1.0,
95
+ zoom_key: str = "",
96
+ color_adjustments_key: str = "",
97
+ is_color_order_bgr: bool = True,
98
+ ) -> None:
99
+ """Send an image to the immdebug_viewer application.
100
+
101
+ Args:
102
+ image: numpy array (HxW for grayscale, HxWxC for multi-channel)
103
+ legend: display name in the viewer
104
+ zoom_center: initial zoom center (x, y)
105
+ zoom_ratio: initial zoom ratio (-1 for auto-fit)
106
+ zoom_key: link zoom across images sharing this key
107
+ color_adjustments_key: link color adjustments across images sharing this key
108
+ is_color_order_bgr: True if channels are BGR (OpenCV default), False for RGB
109
+ """
110
+ _remove_old_images()
111
+
112
+ buf = bytearray()
113
+ _write_string(buf, legend)
114
+ buf += struct.pack("=dd", zoom_center[0], zoom_center[1])
115
+ buf += struct.pack("=d", zoom_ratio)
116
+ _write_string(buf, zoom_key)
117
+ _write_string(buf, color_adjustments_key)
118
+ buf += struct.pack("=?", is_color_order_bgr)
119
+ _write_mat(buf, image)
120
+
121
+ writing_path = _make_unique_filename(while_writing=True)
122
+ done_path = _make_unique_filename(while_writing=False)
123
+
124
+ with open(writing_path, "wb") as f:
125
+ f.write(buf)
126
+
127
+ os.rename(str(writing_path), str(done_path))
immdebug/viewer.py ADDED
@@ -0,0 +1,269 @@
1
+ """Pure Python immdebug viewer using imgui_bundle.
2
+
3
+ Usage:
4
+ python -m immdebug.viewer
5
+
6
+ Polls the temp directory for images sent by immdebug (C++ or Python client)
7
+ and displays them using immvision's inspector.
8
+ """
9
+
10
+ import struct
11
+ import tempfile
12
+ import time
13
+ import threading
14
+ from pathlib import Path
15
+ from typing import Optional, Tuple
16
+
17
+ import numpy as np
18
+
19
+
20
+ # OpenCV depth constants -> numpy dtype
21
+ _CV_DEPTH_TO_DTYPE = {
22
+ 0: np.dtype("uint8"),
23
+ 1: np.dtype("int8"),
24
+ 2: np.dtype("uint16"),
25
+ 3: np.dtype("int16"),
26
+ 4: np.dtype("int32"),
27
+ 5: np.dtype("float32"),
28
+ 6: np.dtype("float64"),
29
+ }
30
+
31
+
32
+ class _ImagePayload:
33
+ def __init__(self) -> None:
34
+ self.image: np.ndarray = np.zeros((0, 0), dtype=np.uint8)
35
+ self.legend: str = ""
36
+ self.zoom_center: Tuple[float, float] = (0.0, 0.0)
37
+ self.zoom_ratio: float = -1.0
38
+ self.zoom_key: str = ""
39
+ self.color_adjustments_key: str = ""
40
+ self.is_color_order_bgr: bool = True
41
+
42
+
43
+ def _read_value(data: bytes, offset: int, fmt: str) -> Tuple:
44
+ size = struct.calcsize(fmt)
45
+ values = struct.unpack_from(fmt, data, offset)
46
+ return values, offset + size
47
+
48
+
49
+ def _read_string(data: bytes, offset: int) -> Tuple[str, int]:
50
+ (length,), offset = _read_value(data, offset, "=Q")
51
+ s = data[offset : offset + length].decode("utf-8")
52
+ return s, offset + length
53
+
54
+
55
+ def _read_mat(data: bytes, offset: int) -> Tuple[np.ndarray, int]:
56
+ (cols, rows, elem_size, elem_type), offset = _read_value(data, offset, "=iiQi")
57
+
58
+ depth = elem_type % 8
59
+ channels = (elem_type // 8) + 1
60
+ dtype = _CV_DEPTH_TO_DTYPE[depth]
61
+
62
+ data_size = cols * rows * elem_size
63
+ pixel_data = data[offset : offset + data_size]
64
+
65
+ if channels == 1:
66
+ image = np.frombuffer(pixel_data, dtype=dtype).reshape((rows, cols))
67
+ else:
68
+ image = np.frombuffer(pixel_data, dtype=dtype).reshape((rows, cols, channels))
69
+
70
+ return image.copy(), offset + data_size
71
+
72
+
73
+ def _read_image_payload(data: bytes) -> _ImagePayload:
74
+ payload = _ImagePayload()
75
+ offset = 0
76
+ payload.legend, offset = _read_string(data, offset)
77
+ (cx, cy), offset = _read_value(data, offset, "=dd")
78
+ payload.zoom_center = (cx, cy)
79
+ (payload.zoom_ratio,), offset = _read_value(data, offset, "=d")
80
+ payload.zoom_key, offset = _read_string(data, offset)
81
+ payload.color_adjustments_key, offset = _read_string(data, offset)
82
+ (payload.is_color_order_bgr,), offset = _read_value(data, offset, "=?")
83
+ payload.image, offset = _read_mat(data, offset)
84
+ return payload
85
+
86
+
87
+ def _immdebug_folder() -> Path:
88
+ return Path(tempfile.gettempdir()) / "ImmDebug"
89
+
90
+
91
+ def _remove_old_images(max_age_seconds: float = 3600.0) -> None:
92
+ folder = _immdebug_folder()
93
+ if not folder.exists():
94
+ return
95
+ now = time.time()
96
+ for entry in folder.rglob("*"):
97
+ if not entry.is_file():
98
+ continue
99
+ try:
100
+ if now - entry.stat().st_mtime > max_age_seconds:
101
+ entry.unlink()
102
+ except OSError:
103
+ pass
104
+
105
+
106
+ def _read_one_payload() -> Optional[_ImagePayload]:
107
+ from imgui_bundle import hello_imgui
108
+
109
+ folder = _immdebug_folder()
110
+ if not folder.exists():
111
+ return None
112
+
113
+ bindata_files = sorted(
114
+ [f for f in folder.iterdir() if f.is_file() and f.suffix == ".bindata"],
115
+ key=lambda f: f.stat().st_mtime,
116
+ )
117
+ if not bindata_files:
118
+ return None
119
+
120
+ oldest = bindata_files[0]
121
+ try:
122
+ data = oldest.read_bytes()
123
+ payload = _read_image_payload(data)
124
+ oldest.unlink()
125
+ return payload
126
+ except Exception as e:
127
+ hello_imgui.log(hello_imgui.LogLevel.error, f"Error reading {oldest.name}: {e}")
128
+ try:
129
+ oldest.unlink()
130
+ except OSError:
131
+ pass
132
+ return None
133
+
134
+
135
+ # --- Single instance app (port of single_instance_app.cpp) ---
136
+
137
+ class _SingleInstanceApp:
138
+ def __init__(self, lock_name: str) -> None:
139
+ self._lock_name = lock_name
140
+ self._exit = threading.Event()
141
+ self._ping_received = threading.Event()
142
+ self._thread: Optional[threading.Thread] = None
143
+
144
+ def _ping_filename(self) -> Path:
145
+ return Path(tempfile.gettempdir()) / f"{self._lock_name}.ping"
146
+
147
+ def run_single_instance(self) -> bool:
148
+ ping_file = self._ping_filename()
149
+
150
+ # Check for stale ping file
151
+ if ping_file.is_file():
152
+ print("Ooops : stale ping file!")
153
+ ping_file.unlink(missing_ok=True)
154
+ time.sleep(0.1)
155
+
156
+ # Create ping file and wait for master to remove it
157
+ ping_file.write_text("Lock")
158
+ time.sleep(0.12)
159
+
160
+ if not ping_file.is_file():
161
+ print("Other instance already launched!")
162
+ return False
163
+
164
+ # We are the first instance
165
+ print("First instance!")
166
+ ping_file.unlink(missing_ok=True)
167
+
168
+ self._thread = threading.Thread(target=self._ping_loop, daemon=True)
169
+ self._thread.start()
170
+ return True
171
+
172
+ def was_pinged(self) -> bool:
173
+ if self._ping_received.is_set():
174
+ self._ping_received.clear()
175
+ return True
176
+ return False
177
+
178
+ def _ping_loop(self) -> None:
179
+ while not self._exit.is_set():
180
+ ping_file = self._ping_filename()
181
+ if ping_file.is_file():
182
+ print("Answering ping!")
183
+ self._ping_received.set()
184
+ ping_file.unlink(missing_ok=True)
185
+ self._exit.wait(timeout=0.06)
186
+
187
+ def stop(self) -> None:
188
+ self._exit.set()
189
+ if self._thread:
190
+ self._thread.join()
191
+
192
+
193
+ # --- Main viewer ---
194
+
195
+ def _focus_window() -> None:
196
+ import glfw
197
+ from imgui_bundle import glfw_utils
198
+ glfw.focus_window(glfw_utils.glfw_window_hello_imgui())
199
+
200
+
201
+ def main() -> None:
202
+ import glfw
203
+ from imgui_bundle import imgui, hello_imgui, immvision
204
+
205
+ single_instance = _SingleInstanceApp("immdebug_viewer")
206
+
207
+ if not single_instance.run_single_instance():
208
+ print("Exit...")
209
+ return
210
+
211
+ frame_count = 0
212
+
213
+ def add_incoming_images() -> bool:
214
+ _remove_old_images()
215
+ found_new = False
216
+ while True:
217
+ payload = _read_one_payload()
218
+ if payload is None:
219
+ break
220
+ hello_imgui.log(hello_imgui.LogLevel.info, f"Received image: {payload.legend}")
221
+ if payload.is_color_order_bgr:
222
+ immvision.use_bgr_color_order()
223
+ else:
224
+ immvision.use_rgb_color_order()
225
+ immvision.inspector_add_image(
226
+ payload.image,
227
+ payload.legend,
228
+ payload.zoom_key,
229
+ payload.color_adjustments_key,
230
+ (payload.zoom_center[0], payload.zoom_center[1]),
231
+ payload.zoom_ratio,
232
+ )
233
+ found_new = True
234
+ if found_new:
235
+ _focus_window()
236
+ return found_new
237
+
238
+ def gui() -> None:
239
+ nonlocal frame_count
240
+ immvision.inspector_show()
241
+
242
+ # Poll for new images every 10 frames
243
+ if frame_count % 10 == 0:
244
+ add_incoming_images()
245
+ frame_count += 1
246
+
247
+ if single_instance.was_pinged():
248
+ _focus_window()
249
+ hello_imgui.log(hello_imgui.LogLevel.warning, "Pong")
250
+
251
+ params = hello_imgui.RunnerParams()
252
+ params.app_window_params.window_geometry.full_screen_mode = (
253
+ hello_imgui.FullScreenMode.full_monitor_work_area
254
+ )
255
+ params.app_window_params.restore_previous_geometry = True
256
+ params.app_window_params.window_title = "ImmVision - immdebug viewer"
257
+ params.callbacks.show_gui = gui
258
+ params.ini_folder_type = hello_imgui.IniFolderType.app_user_config_folder
259
+ params.ini_filename = "immdebug_viewer.ini"
260
+
261
+ glfw.init()
262
+ try:
263
+ hello_imgui.run(params)
264
+ finally:
265
+ single_instance.stop()
266
+
267
+
268
+ if __name__ == "__main__":
269
+ main()
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: immdebug
3
+ Version: 0.1.0
4
+ Summary: Send images to the immdebug viewer for visual debugging — from any Python process
5
+ Project-URL: Homepage, https://github.com/pthom/immvision
6
+ Project-URL: Repository, https://github.com/pthom/immvision
7
+ Project-URL: Issues, https://github.com/pthom/immvision/issues
8
+ Author-email: Pascal Thomet <pthomet@gmail.com>
9
+ License-Expression: MIT
10
+ Keywords: computer-vision,debugging,image,imgui,immvision
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
16
+ Classifier: Topic :: Software Development :: Debuggers
17
+ Requires-Python: >=3.8
18
+ Requires-Dist: glfw
19
+ Requires-Dist: imgui-bundle
20
+ Requires-Dist: numpy
21
+ Description-Content-Type: text/markdown
22
+
23
+ # immdebug
24
+
25
+ Visual image debugger for Python — inspect images from any running process with zoom, pan, pixel values, and colormaps.
26
+
27
+ Part of the [immvision](https://github.com/pthom/immvision) / [Dear ImGui Bundle](https://github.com/pthom/imgui_bundle) ecosystem.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install immdebug
33
+ ```
34
+
35
+ This installs both the client (for sending images) and the viewer (for displaying them).
36
+
37
+ ## Quick start
38
+
39
+ **1. Start the viewer** (in a terminal):
40
+
41
+ ```bash
42
+ immdebug-viewer
43
+ # or: python -m immdebug
44
+ ```
45
+
46
+ **2. Send images** (from your code, a script, a notebook, ...):
47
+
48
+ ```python
49
+ import numpy as np
50
+ from immdebug import immdebug
51
+
52
+ image = np.random.randint(0, 255, (480, 640, 3), dtype=np.uint8)
53
+ immdebug(image, "random noise")
54
+ ```
55
+
56
+ The image appears in the viewer immediately.
57
+
58
+ ## How it works
59
+
60
+ `immdebug()` serializes the image to a temp directory (`<tempdir>/ImmDebug/`). The viewer polls this directory and displays incoming images using [immvision](https://github.com/pthom/immvision)'s inspector.
61
+
62
+ This is the same protocol used by the C++ `ImmDebug()` function, so the Python client works with both the Python and C++ viewers.
63
+
64
+ ## Use with OpenCV
65
+
66
+ ```python
67
+ import cv2
68
+ from immdebug import immdebug
69
+
70
+ image = cv2.imread("photo.jpg")
71
+ immdebug(image, "original")
72
+
73
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
74
+ immdebug(gray, "grayscale")
75
+
76
+ edges = cv2.Canny(gray, 100, 200)
77
+ immdebug(edges, "edges")
78
+ ```
79
+
80
+ Images default to BGR channel order (OpenCV convention). For RGB images (e.g. from PIL or matplotlib), pass `is_color_order_bgr=False`.
81
+
82
+ ## API
83
+
84
+ ```python
85
+ def immdebug(
86
+ image: np.ndarray,
87
+ legend: str = "",
88
+ zoom_center: tuple[float, float] = (0.0, 0.0),
89
+ zoom_ratio: float = -1.0,
90
+ zoom_key: str = "",
91
+ color_adjustments_key: str = "",
92
+ is_color_order_bgr: bool = True,
93
+ ) -> None:
94
+ ```
95
+
96
+ | Parameter | Description |
97
+ |---|---|
98
+ | `image` | numpy array — HxW (grayscale) or HxWxC (color). Supports uint8, int8, uint16, int16, int32, float32, float64. |
99
+ | `legend` | Display name in the viewer |
100
+ | `zoom_center` | Initial zoom center (x, y) |
101
+ | `zoom_ratio` | Initial zoom ratio (-1 for auto-fit) |
102
+ | `zoom_key` | Link zoom/pan across images sharing this key |
103
+ | `color_adjustments_key` | Link color adjustments across images sharing this key |
104
+ | `is_color_order_bgr` | True for BGR (OpenCV), False for RGB (PIL, matplotlib) |
105
+
106
+ ## Features
107
+
108
+ - **Non-blocking**: `immdebug()` just writes a file and returns immediately
109
+ - **Post-mortem**: images persist in the temp directory for 1 hour — start the viewer after your script finishes
110
+ - **Single instance**: launching a second viewer brings the existing one to the front
111
+ - **Cross-language**: works with both the Python and C++ immdebug viewers
112
+
113
+ ## License
114
+
115
+ MIT
@@ -0,0 +1,8 @@
1
+ immdebug/__init__.py,sha256=iJ2Fsmhiso4JorTO2OgguclqKmKe6BH8O3zqwtC3_ws,367
2
+ immdebug/__main__.py,sha256=dh6StRaztiCptl96YPoA7GS1PmUAR7gX_acjHI7qIsI,98
3
+ immdebug/client.py,sha256=PALTTzek1ByRQdh0YuvXIyTa4ax7r4YnCzBBifFg9ZM,3823
4
+ immdebug/viewer.py,sha256=vEV-aL2tU1HP2sO53V1v2AAHCMlydLQgAVWCfAbUClg,7864
5
+ immdebug-0.1.0.dist-info/METADATA,sha256=LHg-fx9dFIjagoalRAwpQ2y2ciPnTxYlW13gwzMt0Kg,3639
6
+ immdebug-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
7
+ immdebug-0.1.0.dist-info/entry_points.txt,sha256=X4TrKG9xwtnvcOluIOrHsI8saPdR0QlLXNQDqJBgtB0,57
8
+ immdebug-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ immdebug-viewer = immdebug.viewer:main