├── LICENSE ├── README.md ├── camera_emi_mapper.py ├── gcode_emi_mapper.py └── output └── Arduino_Uno.png /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Charles Grassin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EMI mapping with RTL-SDR and OpenCV 2 | 3 | Mapping near-field electromagnetic parasitic emissions is useful for the design, debug and pre-compliance testing of electronic devices. Unfortunately, there is no simple way to make EM scans with sufficient level of details/accuracy, speed and reasonable cost. Hence, I developed this solution to make **high-resolution** and **fast** 2D maps of RF EMI for PCBs and more. 4 | 5 | You can find more information and details on my project page (including more examples): http://charleslabs.fr/en/project-Electromagnetic+interference+mapping 6 | 7 | ## Prerequisites 8 | 9 | Hardware requirements: 10 | * A USB camera (for `camera_emi_mapper.py` script only), 11 | * A 3D printer (for `gcode_emi_mapper.py` script only), 12 | * An RTL-SDR with a (DIY?) near-field probe. 13 | 14 | Software dependencies for the python scripts: 15 | * OpenCV (`sudo apt install python3-opencv && pip3 install opencv-contrib-python imutils setuptools`) 16 | * Pyserial (`pip3 instal pyserial`) 17 | * pyrtlsdr (`sudo apt install rtl-sdr && pip3 install pyrtlsdr`) 18 | * numpy, scipy, matplotlib (`pip3 install scipy numpy matplotlib`) 19 | 20 | *These install commands were tested in Ubuntu 19.10.* 21 | 22 | ## Method #1: 3D printer EMI mapping 23 | 24 | To make an EM map with the 3D printer method: 25 | 1. Put the DUT on the printer's bed and the near-field probe attached to the carriage, 26 | 2. Launch the script (optionnal arguments, refer to the help), 27 | 3. Wait for the printer to scan the board, the result will be displayed as soon as it is done. 28 | 29 | Call with `python3 gcode_emi_mapper.py -h` to view the help (arguments description). 30 | 31 | Typical use: `python3 gcode_emi_mapper.py -s /dev/ttyUSB0 -f 100 -z 120 -r 5` (start the script using a 100MHz center frequency, zone size of 120mm, resolution 5mm, where 3D printer is connected to the /dev/ttyUSB0 serial port). 32 | 33 | ## Method #2: Camera EMI mapping 34 | 35 | To make an EM map with the machine vision method: 36 | 1. Launch the script (optionnal arguments, refer to the help), 37 | 2. Properly position the device under test (DUT) in the camera image, 38 | 3. Press "R" to set the position (**the camera and DUT must not move after pressing "R"**), 39 | 4. Put the probe in the frame, press "S", select the probe with the mouse and press "ENTER" to start the scanning, 40 | 5. Scan the DUT by moving the probe, 41 | 6. Press "Q" to exit. If a scan was made, the result is displayed. 42 | 43 | Call with `python3 camera_emi_mapper.py -h` to view the help (arguments description). 44 | 45 | Typical use: `python3 camera_emi_mapper.py -c 1 -f 100` (start the script using a 100MHz center frequency and camera id 1). 46 | 47 | ## Sample result 48 | 49 | This is a scan of an Arduino Uno board performed with this script and a DIY near-field loop probe: 50 | 51 | ![Arduino Uno RF power map.](https://raw.githubusercontent.com/CGrassin/EMI_mapper/master/output/Arduino_Uno.png) -------------------------------------------------------------------------------- /camera_emi_mapper.py: -------------------------------------------------------------------------------- 1 | import imutils #pip3 install imutils 2 | import time 3 | import cv2 #sudo apt install opencv-data opencv-doc python-opencv && pip3 install opencv-contrib-python 4 | from rtlsdr import RtlSdr # pip3 install pyrtlsdr 5 | import scipy.signal 6 | import numpy as np 7 | import matplotlib.pyplot as plt 8 | from scipy.ndimage.filters import gaussian_filter 9 | import argparse 10 | 11 | def gaussian_with_nan(U, sigma=7): 12 | """Computes the gaussian blur of a numpy array with NaNs. 13 | """ 14 | np.seterr(divide='ignore', invalid='ignore') 15 | V=U.copy() 16 | V[np.isnan(U)]=0 17 | VV=gaussian_filter(V,sigma=sigma) 18 | 19 | W=0*U.copy()+1 20 | W[np.isnan(U)]=0 21 | WW=gaussian_filter(W,sigma=sigma) 22 | 23 | return VV/WW 24 | 25 | def print_sdr_config(sdr): 26 | """Prints the RTL-SDR configuration in the console. 27 | """ 28 | print("RTL-SDR config:") 29 | print(" * Using device",sdr.get_device_serial_addresses()) 30 | print(" * Device opened:", sdr.device_opened) 31 | print(" * Center frequency:",sdr.get_center_freq(),"Hz") 32 | print(" * Sample frequency:",sdr.get_sample_rate(),"Hz") 33 | print(" * Gain:",sdr.get_gain(),"dB") 34 | print(" * Available gains:",sdr.get_gains()) 35 | 36 | def get_RMS_power(sdr): 37 | """Measures the RMS power with a RTL-SDR. 38 | """ 39 | samples = sdr.read_samples(1024*4) 40 | freq,psd = scipy.signal.welch(samples,sdr.sample_rate/1e6,nperseg=512,return_onesided=0) 41 | return 10*np.log10(np.sqrt(np.mean(psd**2))) 42 | 43 | # Thanks to https://www.pyimagesearch.com/2015/05/25/basic-motion-detection-and-tracking-with-python-and-opencv/ 44 | # for the tracking tutorial. 45 | def main(): 46 | print("Usage:") 47 | print(" * Press s to select the probe.") 48 | print(" * Press r to reset.") 49 | print(" * Press q to display the EMI map and exit.") 50 | print("Call with -h for help on the args.") 51 | 52 | # parse args 53 | parser = argparse.ArgumentParser(description='EMI mapping with camera and RTL-SDR.') 54 | parser.add_argument('-c', '--camera', type=int, help='camera id (default=0)',default=0) 55 | parser.add_argument('-f', '--frequency', type=float, help='sets the center frequency on the SDR, in MHz (default: 300).',default=300) 56 | parser.add_argument('-g', '--gain', type=int, help='sets the SDR gain (default: 496).',default=496) 57 | args = parser.parse_args() 58 | 59 | # configure SDR device 60 | sdr = RtlSdr() 61 | sdr.sample_rate = 2.4e6 62 | sdr.center_freq = args.frequency * 1e6 63 | sdr.gain = args.gain 64 | sdr.set_agc_mode(0) 65 | #print_sdr_config(sdr) 66 | 67 | # read from specified webcam 68 | cap = cv2.VideoCapture(args.camera) 69 | if cap is None or not cap.isOpened(): 70 | print('Error: unable to open video source: ', args.camera) 71 | else: 72 | # wait some time for the camera to be ready 73 | time.sleep(2.0) 74 | 75 | # initialize variables 76 | powermap = None 77 | firstFrame = None 78 | firstFrameMask = None 79 | 80 | # Init OpenCV object tracker objects 81 | tracker = cv2.TrackerCSRT_create() 82 | init_tracking_BB = None 83 | 84 | # loop while exit button wasn't pressed 85 | while True: 86 | # grab the current frame 87 | ret, frame = cap.read() 88 | 89 | # if the frame could not be grabbed, then we have reached the end 90 | # of the video 91 | if ret == False or frame is None: 92 | break 93 | 94 | # resize the frame, convert it to grayscale, and blur it 95 | frame = imutils.resize(frame, width=500) 96 | gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) 97 | gray = cv2.GaussianBlur(gray, (11, 11), 0) 98 | 99 | # if the first frame is None, initialize it 100 | if firstFrame is None: 101 | firstFrame = frame 102 | firstFrameMask = gray 103 | powermap = np.empty((len(frame),len(frame[0]))) 104 | powermap.fill(np.nan) 105 | continue 106 | 107 | # compute the absolute difference between the current frame and 108 | # first frame 109 | frameDelta = cv2.absdiff(firstFrameMask, gray) 110 | thresh = cv2.threshold(frameDelta, 15, 255, cv2.THRESH_BINARY)[1] 111 | 112 | # dilate the thresholded image to fill in holes, then find contours 113 | # on thresholded image 114 | thresh = cv2.dilate(thresh, None, iterations=2) 115 | 116 | # tracking and reading SDR 117 | if init_tracking_BB is not None: 118 | # grab the new bounding box coordinates of the object 119 | (success, box) = tracker.update(thresh) 120 | 121 | # check to see if the tracking was a success 122 | if success: 123 | (x, y, w, h) = [int(v) for v in box] 124 | # print bounding box 125 | cv2.rectangle(frame, (x, y), (x + w, y + h), 126 | (0, 255, 0), 2) 127 | # fill map 128 | power = get_RMS_power(sdr) 129 | print("RMS power",power,"dBm at",x+w/2,";",y+h/2) 130 | powermap[int(y+h/4):int(y+h/4*3),int(x+w/4):int(x+w/4*3)] = power 131 | 132 | # show the frame (adding scanned zone overlay) 133 | frame[:,:,2] = np.where(np.isnan(powermap),frame[:,:,2],255/2) 134 | cv2.imshow("Frame", frame) 135 | # debug only 136 | #cv2.imshow("Thresh", thresh) 137 | #cv2.imshow("Frame Delta", frameDelta) 138 | 139 | # handle keypresses 140 | key = cv2.waitKey(1) & 0xFF 141 | if key == ord("s") and init_tracking_BB is None: 142 | # select the bounding box 143 | init_tracking_BB = cv2.selectROI("Frame", frame, fromCenter=False, 144 | showCrosshair=True) 145 | 146 | # start OpenCV object tracker 147 | tracker.init(thresh, init_tracking_BB) 148 | elif key == ord("q"): 149 | break 150 | elif key == ord("r"): 151 | firstFrame = None 152 | 153 | # gracefully free the resources 154 | sdr.close() 155 | cap.release() 156 | cv2.destroyAllWindows() 157 | 158 | # generate picture 159 | if init_tracking_BB is not None and powermap is not None and firstFrame is not None: 160 | blurred = gaussian_with_nan(powermap, sigma=7) 161 | plt.imshow(cv2.cvtColor(firstFrame, cv2.COLOR_BGR2RGB)) 162 | plt.imshow(blurred, cmap='hot', interpolation='nearest',alpha=0.55) 163 | plt.axis('off') 164 | plt.title("EMI map (min. "+"%.2f" % np.nanmin(powermap)+" dBm, max. "+"%.2f" % np.nanmax(powermap)+" dBm)") 165 | plt.show() 166 | # TODO : add distribution plot 167 | else: 168 | print("Warning: nothing captured, nothing to do") 169 | 170 | if __name__== "__main__": 171 | main() -------------------------------------------------------------------------------- /gcode_emi_mapper.py: -------------------------------------------------------------------------------- 1 | import serial # pip3 instal pyserial 2 | import argparse 3 | import time 4 | from rtlsdr import RtlSdr # pip3 install pyrtlsdr 5 | import numpy as np 6 | import matplotlib.pyplot as plt 7 | import camera_emi_mapper 8 | 9 | def send_gcode(s, gcode, wait_for_ok=True): 10 | #print(gcode) 11 | s.write(gcode + b'\n') 12 | if wait_for_ok: 13 | ack_ok = False 14 | while not ack_ok: 15 | line = s.readline() 16 | #print(line) 17 | if line == b'ok\n': 18 | ack_ok = True 19 | 20 | def main(): 21 | parser = argparse.ArgumentParser(description='EMI mapping with 3D-printer and RTL-SDR.') 22 | parser.add_argument('-p', '--serial-port', type=str, help='printer serial port.',default='/dev/ttyACM0') 23 | parser.add_argument('-b', '--baud-rate', type=int, help='printer serial baud rate (default: 250000 for Marlin).',default=250000) 24 | parser.add_argument('-f', '--frequency', type=float, help='sets the center frequency on the SDR, in MHz (default: 300).',default=300) 25 | parser.add_argument('-g', '--gain', type=int, help='sets the SDR gain in 0.1dB (default: 496).',default=496) 26 | parser.add_argument('-z', '--zone-size', type=int, help='size of the zone to map in mm (default: 100).',default=100) 27 | parser.add_argument('-r', '--resolution', type=int, help='resolution of the grid in mm (default: 10).',default=10) 28 | parser.add_argument('--px-mm', type=int, help='final picture pixels per mm (default: 10).',default=10) 29 | parser.add_argument('--feedrate', type=int, help='feedrate percentage, put more than 100 for greater speed (default: 100).',default=100) 30 | args = parser.parse_args() 31 | 32 | # Args 33 | # General 34 | gridStep=args.resolution; #mm 35 | gridSize=args.zone_size; #mm 36 | pixelPerMM=args.px_mm; #px/mm 37 | feedrate = args.feedrate; #% 38 | # SDR stuff 39 | frequency=args.frequency; #MHz 40 | gain = args.gain; #0.1dB 41 | # Serial 42 | port=args.serial_port; 43 | baudrate=args.baud_rate; 44 | 45 | # Vars 46 | steps=int(gridSize/gridStep); 47 | 48 | # Open serial port 49 | s = serial.Serial(port, baudrate,timeout=1) 50 | 51 | # Open SDR 52 | sdr = RtlSdr() 53 | sdr.sample_rate = 2.4e6 54 | sdr.center_freq = frequency * 1e6 55 | sdr.gain = gain 56 | sdr.set_agc_mode(0) 57 | power = camera_emi_mapper.get_RMS_power(sdr) #First read doesn't work 58 | 59 | # Prepare canvas 60 | powermap = np.empty((gridSize*pixelPerMM,gridSize*pixelPerMM)) 61 | powermap.fill(np.nan) 62 | cell_size=(gridStep*pixelPerMM) 63 | 64 | # Init. machine 65 | s.write(b'\n') 66 | time.sleep(2) 67 | line = '' 68 | while not (line == b'ok\n' or line == b'echo:SD card ok\n'): 69 | line = s.readline() 70 | 71 | send_gcode(s,b'G21') # Set unit to mm 72 | send_gcode(s,b'G90') # Set to *Absolute* Positioning 73 | 74 | send_gcode(s,b'G28 XY') # Home XY 75 | send_gcode(s,b'M400') # Wait for current moves to finish 76 | 77 | send_gcode(s,b'M220 S' + str(feedrate).encode()) # Goto XY zero 78 | 79 | send_gcode(s,b'G0 X0Y0Z0') # Goto XY zero 80 | send_gcode(s,b'M400') # Wait for current moves to finish 81 | 82 | # Go through the grid 83 | direction = True; 84 | for y in range(steps): 85 | # Fast Move (Y axis) 86 | send_gcode(s,b'G0 Y' + str(y*gridStep).encode()) 87 | send_gcode(s,b'M400') 88 | 89 | x_iter = range(steps) 90 | if not direction: 91 | x_iter = reversed(x_iter) 92 | 93 | for x in x_iter: 94 | send_gcode(s,b'G0 X' + str(x*gridStep).encode()) # Fast Move (X axis) 95 | send_gcode(s,b'M400') # Wait for current moves to finish 96 | 97 | # Measure 98 | power = camera_emi_mapper.get_RMS_power(sdr) 99 | print("RMS power",power,"dBm at (",x*gridStep,"mm;",y*gridStep, "mm)") 100 | powermap[int(y*cell_size):int(y*cell_size+cell_size),int(x*cell_size):int(x*cell_size+cell_size)] = power 101 | 102 | direction = not direction 103 | send_gcode(s,b'M18') # SRelease steppers 104 | 105 | # Close ressources 106 | s.close() 107 | sdr.close() 108 | 109 | # Display picture 110 | blurred = camera_emi_mapper.gaussian_with_nan(powermap, sigma=3*pixelPerMM) 111 | plt.imshow(blurred, cmap='hot', interpolation='nearest',alpha=1, extent=[0,gridSize,0,gridSize]) 112 | plt.title("EMI map (min. "+"%.2f" % np.nanmin(powermap)+" dBm, max. "+"%.2f" % np.nanmax(powermap)+" dBm)") 113 | plt.xlabel("mm") 114 | plt.ylabel("mm") 115 | plt.show() 116 | 117 | if __name__== "__main__": 118 | main() 119 | -------------------------------------------------------------------------------- /output/Arduino_Uno.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CGrassin/EMI_mapper/b01e4eb402137c64d3c1f442d94b0aef18bc3388/output/Arduino_Uno.png --------------------------------------------------------------------------------