A cross-platform Python GUI application that allows you to map Wi-Fi signal strength over a floor plan or map image. By taking signal measurements at various points in a physical space, the tool generates an interpolated, physics-based heatmap to visualize your exact network coverage.
Example:
- Not tested on MAC
- Not tested with 6GHz Wi-Fi
- Installation
- OS-Specific Requirements
- How to Use
- How it Works
- Signal Conversion: dBm to Percentage
- Debugging
- License
-
Clone the repository:
git clone https://github.com/J0nan/Wi-Fi_HeatMap cd Wi-Fi_HeatMap -
Create a virtual environment
python -m venv Wi-Fi_HeatMap
-
Activate virtual environment
# Linux source ./Wi-Fi_HeatMap/bin/activate # Windows .\Wi-Fi_HeatMap\Scripts\activate.bat
-
Install the required dependencies:
pip install -r requirements.txt
- Windows: Works out of the box using
netsh. Thepywifilibrary is also supported and recommended (it is installed with therequirements.txt).
- Linux: Requires NetworkManager (
nmcli) to be installed and active. Scanning requires sufficient privileges; running assudomight be necessary depending on your distro's network permissions.
Run the application from your terminal:
.\Wi-Fi_HeatMap\Scripts\activate.bat
python Wi-Fi-heatmap.pySet-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned -Force
.\Wi-Fi_HeatMap\Scripts\activate.ps1
python Wi-Fi-heatmap.pysource ./Wi-Fi_HeatMap/bin/activate
python Wi-Fi-heatmap.py- Select Interface: Choose your active Wi-Fi adapter from the dropdown on the left panel.
- Load Map: Click Load Map Image and select a floor plan of your space (PNG, JPG, BMP).

- Calibration:
- Measure:
- Generate Heatmap:
- Save/Export: Export the final image as a PNG, or click Save Session on the sidebar to save your raw data and map into a single file for later use.
This section is aimed at developers who want to understand the codebase. The entire application lives in a single file Wi-Fi-heatmap.py built around the WifiHeatmapApp class.
┌──────────────────────────────────────────────────────────┐
│ main() │
│ Creates Tk root → WifiHeatmapApp(root) → mainloop() │
└──────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ WifiHeatmapApp.__init__ │
│ 1. Detects OS (platform.system()) │
│ 2. Sets up UI (setup_ui) │
│ 3. Discovers Wi-Fi interfaces (load_interfaces) │
└──────────────────────────────────────────────────────────┘
The application is a finite-state machine that transitions between states driven by user actions:
| State | Meaning | Triggered by |
|---|---|---|
IDLE |
Default resting state | Startup, or finishing calibration/measuring |
CALIBRATING |
Waiting for two reference clicks on the map | "Calibrate Map" button |
MEASURING |
Each click triggers a Wi-Fi scan at that position | "Start Measuring" toggle |
On startup the app enumerates available Wi-Fi adapters using the OS-native backend:
| OS | Backend | Data source |
|---|---|---|
| Windows | pywifi library (preferred) or netsh wlan show interfaces |
Adapter name + description |
| Linux | nmcli -t -f DEVICE,TYPE device |
Devices with type wifi |
| macOS | networksetup -listallhardwareports |
Entries under "Hardware Port: Wi-Fi" |
The detected interfaces populate the sidebar dropdown. An internal interfaces_map dictionary maps each display string to its OS-level handle (a pywifi.Interface object on Windows, or a plain interface name string on Linux/macOS).
The user selects an image file (PNG/JPG/BMP). The image is opened with Pillow, converted to RGB, and stored as a NumPy array in self.original_image. It is rendered on a Matplotlib figure embedded inside the Tkinter window via FigureCanvasTkAgg. A black border is drawn around the image edges to provide a visual boundary and enforce that clicks outside the image are discarded.
Calibration establishes the pixel-to-meter ratio required by the physics engine:
- The user clicks two points on the map whose real-world distance is known.
- A dialog asks for that distance in meters.
- The Euclidean pixel distance between the two clicks is divided by the real-world distance to produce
self.pixels_per_meter.
This ratio is used later to convert pixel distances on the grid into physical meters, which in turn feed into the Free-Space Path Loss formula.
When in MEASURING state, each click on the map:
- Validates that the Wi-Fi adapter is still powered on (
is_wifi_on). - Performs 3 sequential Wi-Fi scans (
scan_wifi_once× 3) with a 1-second pause between them. - Averages the signal strength across scans for each detected SSID.
- Stores the result as a dictionary appended to
self.measurements:
{
'x': int, # Pixel X coordinate on the map
'y': int, # Pixel Y coordinate on the map
'ssids': {
'NetworkName': {
'signal': int, # Normalized 0–100 % (see dBm conversion below)
'freq': float # Center frequency in MHz (e.g., 2437.0, 5180.0)
},
...
}
}The scan_wifi_once method is the OS-abstraction layer. It calls the native scanning tool, parses its output, and returns a dict of {ssid: {signal, freq}}:
| OS | Tool | Signal source | Frequency source |
|---|---|---|---|
Windows (pywifi) |
interface.scan() → scan_results() |
Raw dBm from driver | network.freq (auto-detected unit) |
Windows (netsh) |
netsh wlan show networks mode=bssid |
Percentage → converted to dBm → normalized | Channel number → channel_to_freq() |
| Linux | nmcli dev wifi list |
Percentage → converted to dBm → normalized | Frequency field in MHz |
| macOS | airport -s |
Raw dBm (RSSI column) | Channel number → channel_to_freq() |
A helper function channel_to_freq() maps Wi-Fi channel numbers to their center frequency in MHz, covering the 2.4 GHz (channels 1-14), 5 GHz (36-165), and 6 GHz (1-233) bands.
This is the core computation. Given all measurement points for a selected SSID, the algorithm produces a 2D signal-strength grid over the entire map:
-
Grid creation: A 200×200 evaluation grid is built over the image dimensions using
np.mgrid. -
Per-measurement propagation loop: For each measurement point
(xi, yi)with signalzi_percentat frequencyfreq_mhz:a. Back-convert percentage → dBm:
$$dBm = \left(\frac{percent \times 60}{100}\right) - 100$$ b. Reverse FSPL → virtual AP distance: Using a reference transmit power of 0 dBm, the path loss observed at the measurement point is inverted to find how far the AP appears to be:
$$d_{AP} (km) = 10^{\frac{PathLoss - 20\log_{10}(f) - 32.44}{20}}$$ c. Forward FSPL → predict signal at every grid cell: The total distance from the virtual AP to each grid cell is
d_AP + d_cell, whered_cellis the physical distance (meters → km) from the measurement point to the grid cell. The predicted dBm is then:$$predicted_{dBm} = 0 - FSPL(d_{total}, f) - 1.2 \times d_{cell}$$ The
1.2 × d_cellterm is an indoor absorption penalty that simulates wall/furniture attenuation and forces realistic signal decay.d. Convert to linear milliwatts:
predicted_mW = 10^(predicted_dBm / 10) -
Inverse Distance Weighting (IDW) blending: Each measurement's predicted milliwatt grid is accumulated using weights
w = 1 / (distance² + ε). This ensures nearby measurement points dominate, while distant ones contribute less. -
Back-conversion: The blended milliwatt grid is converted back to dBm (
10 × log10), then to percentage, and finally clipped to[0, 100]. -
Transparency masking: Any grid cell below 35% signal strength is masked out (
NaN), rendering it fully transparent over the floor plan. -
Visualization: The heatmap is overlaid on the floor plan in a new Toplevel window using a custom colormap (
red → blue → green), where green = strong signal and red = weak signal. A colorbar and the original measurement dots are also shown.
Sessions are stored as a single .json file containing:
{
"image_base64": "<base64-encoded PNG of the floor plan>",
"pixels_per_meter": 42.5,
"measurements": [ ... ]
}The map image is embedded as base64 directly in the JSON, making sessions fully portable across machines (no dependency on a local file path). On load, the base64 is decoded back into a Pillow Image, converted to a NumPy array, and all UI state (calibration label, button states, SSID dropdown) is restored.
This application converts all signals back to their linear physical power equivalent (milliwatts) and routes them through a theoretical environmental decay model:
Where:
- d = distance in kilometers (derived from your map calibration).
- f = the exact frequency of the AP in MHz (dynamically scraped during the scan, automatically adjusting decay rates for 2.4 GHz, 5 GHz, or 6 GHz networks).
The resulting physical power is then mathematically blended and converted back into an intuitive 0-100% UI scale.
All signal strengths in the application are normalized to a 0 – 100 % scale using a strict linear mapping anchored at two reference points:
| dBm | Percentage | Meaning |
|---|---|---|
| -40 dBm | 100 % | Excellent — essentially next to the AP |
| -100 dBm | 0 % | No usable signal |
The conversion is performed by the dbm_to_percent function (defined inside scan_wifi_once):
def dbm_to_percent(dbm_val):
return max(0, min(100, int(round((dbm_val + 100.0) * 100.0 / 60.0))))Mathematically:
The 60 in the denominator comes from the dynamic range: -40 - (-100) = 60 dB.
Different operating systems report Wi-Fi signal in different units:
| OS / Tool | Native unit | What the app does |
|---|---|---|
pywifi (Windows) |
Raw dBm | Direct → dbm_to_percent |
netsh (Windows) |
Proprietary 0-100 % | Reverse-map to dBm via dBm = (win% / 2) - 100, then normalize |
nmcli (Linux) |
0-100 % | Same reverse-map as Windows netsh |
airport (macOS) |
Raw dBm (RSSI) | Direct → dbm_to_percent |
By funnelling every backend through the same dBm → percentage pipeline, measurements are consistent across platforms and across scanning backends on the same OS (e.g., pywifi vs netsh on Windows will produce the same percentage for the same physical signal).
The application includes a logging system. If you are experiencing issues with Wi-Fi adapters not scanning or map generation failing, launch the app with an elevated log level:
python main.py --log-level DEBUGAvailable levels: DEBUG, INFO, WARNING, ERROR, CRITICAL.
This project is licensed under the GPL-3.0 license - see the LICENSE file for details.



