OCR for Aqua Medic mV Controller 2001 C

The Aqua Medic mV Controller 2001C which provides no serial/USB connection to obtain the current measurements or the On/Off state of the pump.

Why the need for OCR ?

To help reducing the nitrates in my reef, I’m using an Aqua Medic Sulphur Reactor SN400 (alongside with the Redox Plastic Probe mV PG 13.5 short plus the Aqua Medic mV Controller 2001C controller) and an Aqua Medic SP1500 peristaltic pump to push water through the reactor once the Redox potential drops below the set reference point.

I encountered many troubles with the Aqua Medic SP1500 pumps (hose gets too loose and is pushed out by the rotor, the plastic cross with rollers gets cracked, plastic parts are used inside the gear housing and after a while they make very loud noises when starting and eventually the motor grinds to a halt). Sometimes the pump stops in a position where only one roller pushes against the hose, and allows a little water to still pass – and thus, the reactor gets out of balance (due to a trickle of oxygen rich water entering it).

The mv Controller 2001 C (and its Redox probe) are the most robust one so far – the reading it shows on a large display is the best indicator that something is off. Usually it should stay around the reference point, and if is not – then there is a problem.

With all the randomness described above (not to mention that the Sulphur media gets depleted in time), it would be nice if there were some charts I could check to spot various problems. But how to get the big red -239 value above out of the controller ?


I remembered reading about people that used cameras watching their meters (plus a bit of magic OCR) to keep track of their electricity (or water) consumption. I pondered also about opening the controller up and try to reverse engineer the display logic – it could take a while, is more risky and may leave the aquarium without the help of the reactor.

Thus, the webcam solution (used a Microsoft LifeCam HD-6000 attached to a small tripod, pointing down towards the LCD):


After a lot of reading (will post the links at the bottom of the article) about various ways of doing OCR, I realized the task is very easy. First, the display is an LCD which means the digits stay at the same place, instead of rolling around like in the mechanical meters (camera is fixed too). Second, the LCD is always lit, meaning I don’t need to provide a source of light.

One very usable idea is to sample a pixel within the center of each 7 segment and check its color. If is very bright, then the segment is on. It then becomes a matter of creating a state based on each segment of the digit and then match the state with a real value using a bunch of ifs.

I’ve used: Oracle JDK 1.8 64bit, OpenCV 2.4.11 (with the 64bit opencv_java2411.dll) to access the webcam and Netbeans graphical editor to create a small application:

There are some black pixels, just to help with debugging (to see if the relative coordinates of the pixels line up within the digit box). Since the camera is not perfectly straight onto the LCD, each digit box is a bit different than the other, but it still works, allowing me to define the pixels relative to a box, and then just define the position of each digit box:

Top right LED turns on when the relay is on – so when the pump is turned on. This is also checked by the code:

The most important part is the “OCR” detection below:

 * @author viulian
public class OCRImage {

    // each digit 36px x 51px
    private static final int minusBoxAtXY[] = {98, 69};
    private static final int hundredBoxAtXY[] = {138, 69};
    private static final int tensBoxAtXY[] = {178, 69};
    private static final int unitBoxAtXY[] = {218, 69};
    private static final int relayLEDAtXY[] = {264, 76};
    // Taken relative to the hundredBoxAtXY
    private static final int segment_1[] = {20, 6}; // 158x74 - 138x69
    private static final int segment_2[] = {30, 15}; // 168x84 - 138x69
    private static final int segment_3[] = {28, 36}; // 166x105 - 138x69
    private static final int segment_4[] = {14, 46}; // 152x115 - 138x69
    private static final int segment_5[] = {7, 36}; // 145x105 - 138x69
    private static final int segment_6[] = {7, 15}; // 145x84 - 138x69 
    private static final int segment_7[] = {17, 26}; // 155x95 - 138x69
    public static String getTextFromImage(BufferedImage image) {
        String value = "";
        value += isRelayOn(image, relayLEDAtXY[0], relayLEDAtXY[1]) ? "*" : "";
        value += hasMinus(image, minusBoxAtXY[0], minusBoxAtXY[1]) ? "-" : "";
        value += getDigit(image, hundredBoxAtXY[0], hundredBoxAtXY[1]);
        value += getDigit(image, tensBoxAtXY[0], tensBoxAtXY[1]);
        value += getDigit(image, unitBoxAtXY[0], unitBoxAtXY[1]);
        return value;
    // Check segment_7 (relative to the given box).
    private static boolean hasMinus(BufferedImage image, int boxX, int boxY) {
        return isPixelOn(image, boxX + segment_7[0], boxY + segment_7[1]);
    private static boolean isRelayOn(BufferedImage image, int boxX, int boxY) {
        return isPixelOn(image, boxX, boxY);
    private static String getDigit(BufferedImage image, int boxX, int boxY) {
        String state = "";
        state += isPixelOn(image, boxX + segment_1[0], boxY + segment_1[1]) ? "1" : "0";
        state += isPixelOn(image, boxX + segment_2[0], boxY + segment_2[1]) ? "1" : "0";
        state += isPixelOn(image, boxX + segment_3[0], boxY + segment_3[1]) ? "1" : "0";
        state += isPixelOn(image, boxX + segment_4[0], boxY + segment_4[1]) ? "1" : "0";
        state += isPixelOn(image, boxX + segment_5[0], boxY + segment_5[1]) ? "1" : "0";
        state += isPixelOn(image, boxX + segment_6[0], boxY + segment_6[1]) ? "1" : "0";
        state += isPixelOn(image, boxX + segment_7[0], boxY + segment_7[1]) ? "1" : "0";
        if (state.equals("0110000")) {
            return "1";
        } else if (state.equals("1101101")) {
            return "2";
        } else if (state.equals("1111001")) {
            return "3";
        } else if (state.equals("0110011")) {
            return "4";
        } else if (state.equals("1011011")) {
            return "5";
        } else if (state.equals("1011111")) {
            return "6";
        } else if (state.equals("1110000")) {
            return "7";
        } else if (state.equals("1111111")) {
            return "8";   
        } else if (state.equals("1111011")) {
            return "9";
        } else if (state.equals("1111110")) {
            return "0";
        } else {
            try {
                // Don't care keeping ALL errors files, just last one.
                ImageIO.write(image, "png", new File("error.png"));
            } catch (IOException ex) {
                Logger.getLogger(OCRImage.class.getName()).log(Level.SEVERE, null, ex);
            return "x"; // error

    private static boolean isPixelOn(BufferedImage image, int x, int y) {
        int rgb = image.getRGB(x, y);
        int alpha = (rgb >> 24) & 0xFF;
        int red = (rgb >> 16) & 0xFF;
        int green = (rgb >> 8) & 0xFF;
        int blue = (rgb) & 0xFF;
        image.setRGB(x, y, 0);
        return red > 200 && green > 200 && blue > 200;

The values are sent to Graphite server and below are the results for the last 24 hours:

Last 24 hours

More charts:


  • The Aqua Medic LCD is not fast enough when switching digits – and the camera catches those changes – producing errors. These values are not sent to the Graphite:
  • To reduce the CPU load, camera captures only 320×240 pixels, and this seems good enough for the purpose.
  • The graph is not uniform throughout the day and you can see there are some cycles that are twice or even three times as long as others. When I check the pump, it happens that just one roll pushes against the hose so probably Aqua Medic SP1500 allows a little bit of water to still pass through, making it harder for the oxygen to be depleted within the reactor:
  • Moving the button “Set” to the set position, changes the LCD value to the reference (the camera will pick on that). So expect spikes in the charts if the reference point is changed while the software is running.
  • Since the camera records constantly, measurements are sent to Graphite only when value changes. Thus, there is a need to use the keepLastValue() function in Graph Data – otherwise there will be gaps for the minutes where values doesn’t change.
  • Maximize the flow through the reactor is possible. With the reference set at -240mV, the cycle takes about 32 minutes and the pump is on for about 59s to 1m04s. With the reference set at -285mV, the cycle took about 10 minutes more to complete, and a little bit less time to keep the relay on (pump was on about 48s).
  • Estimated flow in the current setup: if the relay turns on about 46 times per day for about 1 minute, it means about 1.15L of NO3 clean water is pumped back into the aquarium per day (as the SP1500 has a flow of 1.5L/h)
  • Expanding on the problems with Aqua Medic setup, there were also a leak problem with the SN400 reactor. Ended up replacing the case an pump with one of a KR400 I found discounted at half the price. Teflon tape didn’t fix the problem and I suspected plastic deformation. The reactors are identical (except for the active content).
  • There was a problem with the Microsoft LifeCam HD-6000 – I don’t this is capable of focusing very close and it was constantly trying to focus hunt. Luckily OpenCV allows to turn off the autofocus feature:
    videoCapture.set(Highgui.CV_CAP_PROP_FOCUS, 0);


  1. Decoding images of seven-segment display devices –
  2. Automatic Water Meter Reading with a Webcam –
  3. Seven Segment Optical Character Recognition –
  4. Read and recognize the counter of an electricity meter with OpenCV


Leave a Reply