OMR Bubble Sheet Scanner in Flutter

Build an Android app that grades bubble sheet answers using OpenCV.

Introduction

30 Second Summary

Teachers and tutors spend hours grading multiple-choice tests by hand. Automated bubble sheet scanners can do the same job in seconds, but commercial solutions cost hundreds of dollars per year.

In this project, you will build a Flutter Android app that loads a photo of a filled-in bubble sheet, uses OpenCV to detect which bubbles are marked, and displays the score.

What You'll Build

A Flutter Android app called "OMR Grader" that processes photos of 5-question bubble sheets using on-device computer vision to identify marked answers and calculate a score.

By the end of this project, you'll have:

  • A Flutter app with a clean UI showing the captured image and the computed score.
  • An OMR processing engine using opencv_dart that converts images to binary, detects bubble contours, and reads marked answers via pixel density analysis.
  • A grading system that compares detected answers against a hardcoded answer key and calculates a score.
  • Secret Mission: Add a camera capture flow so you can scan sheets in real-time instead of loading from gallery.

Do I need any prior experience?

No prior Flutter or OpenCV experience is required. This guide walks you through installing every tool from scratch. You do need a physical Android phone connected via USB for testing, since the camera and image processing features do not work reliably on emulators.

Before We Start

Before We Start...

Set Up Flutter and Android Development Environment

Before you can build an OMR scanner, you need a complete mobile development environment on your computer. Flutter handles the app framework, OpenCV handles the computer vision, and your physical Android phone is where everything runs.

In this step, you will install all the tools that make Flutter development possible on Windows, create your project, and confirm it runs on your phone.

In this step, get ready to:
  • Install Git, VS Code, Flutter SDK, and Android Studio.
  • Create the Flutter project and configure OpenCV dependencies.
  • Build and run the default app on your physical Android phone.
Install prerequisites

Flutter on Windows requires four tools working together: Git (for version control), VS Code (your code editor), the Flutter SDK (the framework itself), and Android Studio (which provides the Android build tools).

  • Open your browser and navigate to https://git-scm.com/download/win to download Git for Windows.
  • Run the downloaded installer and accept all the default settings.
  • When the installer finishes, open a new terminal and verify Git is available by running:
git --version

✔️ I see a version number

Git is installed and ready. Move on to installing VS Code.

ⓧ 'git' is not recognized

The installer may not have added Git to your system PATH. Try closing and reopening your terminal. If it still does not work, re-run the Git installer and on the "Adjusting your PATH" screen, select the option that adds Git to your PATH.

  • Download and install VS Code from https://code.visualstudio.com.
  • Open VS Code after installation by pressing the Windows key, typing VS Code, and pressing Enter.
  • Install the Flutter extension by clicking the Extensions icon in the left sidebar (or pressing Ctrl+Shift+X), searching for Flutter, and clicking Install on the official Flutter extension.

What does the Flutter extension do?

The Flutter extension adds code completion, debugging tools, and most importantly, the ability to download and manage the Flutter SDK directly from VS Code.

  • Open the VS Code command palette by pressing Ctrl+Shift+P.
  • Type Flutter: New Project and press Enter.
  • VS Code will detect that Flutter is not installed and prompt you to download the SDK. Click Download SDK when prompted.
  • When asked where to install, choose a short path like C:\Flutter. This avoids Windows path length errors.
  • Wait for the SDK download to complete. VS Code will show progress in the bottom status bar.

Why a short install path?

Windows has a 260-character path limit by default. The Flutter SDK has deeply nested folders, so installing at a long path like C:\Users\YourName\Documents\Development\Flutter can trigger "Filename too long" errors during builds.

  • Download Android Studio from https://developer.android.com/studio.
  • Run the installer and follow the setup wizard.
  • On the SDK Components Setup screen, make sure Android SDK is checked.
  • Complete the setup wizard and let Android Studio download the SDK components.

Why install Android Studio if you code in VS Code?

Android Studio provides the Android SDK and build tools that Flutter needs to compile your app into an Android package. You will not write code in Android Studio. VS Code with the Flutter extension is your primary editor.

  • Open a new terminal in VS Code by pressing Ctrl+` (backtick key).
  • Run the Flutter doctor command to check that all tools are properly connected:
flutter doctor -v

✔️ I see Flutter 3.38 or higher with no errors

Your development environment is fully configured. Move on to connecting your phone.

ⓧ I see errors or warnings

The most common issues are:

  • Android toolchain: Open Android Studio, go to Settings > Languages & Frameworks > Android SDK > SDK Tools tab, and install Android SDK Command-line Tools.
  • Android licenses: Run flutter doctor --android-licenses and accept all licenses by typing y when prompted.
  • Re-run flutter doctor -v after each fix until all checks pass.

ⓧ 'flutter' is not recognized

VS Code may not have added Flutter to your PATH. Close and reopen VS Code. If the issue persists, manually add C:\Flutter\bin to your Windows system PATH environment variable, then restart your terminal.

Now connect your physical Android phone so Flutter can deploy apps to it.

  • On your Android phone, open Settings.
  • Scroll down and tap About phone (on some phones this is under System).
  • Find Build number and tap it seven times. You will see a message saying "You are now a developer!"
  • Go back to Settings and open Developer options (it may be under System).
  • Toggle on USB debugging.
  • Connect your phone to your computer with a USB cable.
  • When your phone asks "Allow USB debugging?", tap Allow and check "Always allow from this computer".
  • Verify that Flutter can see your phone by running:
flutter devices

✔️ I see my device listed

Your phone is connected and ready for app deployment.

ⓧ No devices detected

  • Make sure your USB cable supports data transfer (not just charging).
  • On your phone, pull down the notification shade and tap the USB notification. Select File Transfer or MTP mode.
  • Try a different USB port on your computer.
  • Run flutter devices again after each change.
Create the Flutter project and add dependencies

With your environment verified, you can create the project and add the two packages that power the OMR scanner: opencv_dart for image processing and image_picker for selecting photos from the gallery.

  • In your VS Code terminal, navigate to your Desktop by running:
cd ~/Desktop
  • Create your Flutter project by running:
flutter create omr_grader
  • Move into the project folder by running:
cd omr_grader
  • Open the project in VS Code by running:
code .

What did flutter create do?

This command scaffolded a complete Flutter project with a default counter app, an Android build configuration, and a pubspec.yaml file where you declare your dependencies.

  • In VS Code's Explorer sidebar, click on pubspec.yaml in the project root to open it.
  • Replace the entire file contents with the following configuration:
name: omr_grader
description: "OMR Bubble Sheet Scanner and Grader"
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: ^3.10.0
  flutter: ">=3.38.0"

dependencies:
  flutter:
    sdk: flutter
  opencv_dart: ^2.2.1
  image_picker: ^1.1.2

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true

hooks:
  user_defines:
    dartcv4:
      include_modules:
        - imgcodecs
        - imgproc

What does this configuration do?

The environment section pins the minimum Dart SDK to 3.10.0 and Flutter to 3.38.0, which are the minimum versions that opencv_dart requires.

The hooks section tells opencv_dart which OpenCV modules to include in the build. You only need imgcodecs (for reading and writing images) and imgproc (for image processing operations like thresholding and contour detection). Including fewer modules reduces build time and app size.

  • Save pubspec.yaml by pressing Ctrl+S.
  • Install the dependencies by running:
flutter pub get

Seeing dependency errors?

  • Check that your YAML indentation uses exactly 2 spaces (not tabs). YAML is whitespace-sensitive.
  • Confirm the package names are spelled exactly as shown: opencv_dart and image_picker.
  • Make sure you saved the file before running flutter pub get.

Why are my Flutter dependencies failing?

✔️ Awesome, I've got everything!

Great. Double check you have saved pubspec.yaml and the terminal shows no errors.

ⓧ I'd like to double check the full code

Here is the complete pubspec.yaml for reference:

name: omr_grader
description: "OMR Bubble Sheet Scanner and Grader"
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: ^3.10.0
  flutter: ">=3.38.0"

dependencies:
  flutter:
    sdk: flutter
  opencv_dart: ^2.2.1
  image_picker: ^1.1.2

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true

hooks:
  user_defines:
    dartcv4:
      include_modules:
        - imgcodecs
        - imgproc
First build and verification

Your first build will take significantly longer than usual because opencv_dart compiles OpenCV from source during the initial build. This is a one-time cost.

  • Make sure your phone is still connected (check with flutter devices if unsure).
  • Build and deploy the app to your phone by running:
flutter run

Why does the first build take so long?

Since opencv_dart version 2.2.0+, the package builds OpenCV 4.13.0 from source during your first Flutter build. This downloads approximately 100MB and compiles native libraries for your phone's architecture.

Expect 10-15 minutes on the first build. Subsequent builds will be fast because the compiled libraries are cached.

  • Wait for the build to finish. You will see progress messages in the terminal.
  • Once the build completes, the default Flutter counter app appears on your phone. Tap the + button to confirm the app is responsive.

Build failed or app not appearing?

  • If the build fails with a network error, check your internet connection. The first build downloads OpenCV source files.
  • If you see a Gradle error, make sure Android Studio finished its SDK setup completely. Re-run flutter doctor -v to confirm no issues remain.
  • If the app does not appear on your phone, ensure the phone screen is unlocked and USB debugging is still enabled.

Help me fix my Flutter build errors.

Your development environment is fully set up with Flutter, OpenCV, and a verified connection to your phone. Next up, you will build the app's user interface for loading and displaying bubble sheet images.

Build the App UI

Your Flutter project is set up and the default counter app runs on your phone. Now it's time to replace that template with the actual OMR Grader interface.

The UI needs an image display area to show the loaded bubble sheet, two action buttons (Load Image and Grade), and a results section that shows the score breakdown. You will build all of this in a single file that replaces the default counter app.

In this step, get ready to:
  • Create the main screen layout with buttons and display areas.
  • Implement image loading from gallery using image_picker.
  • Wire up the UI state so loaded images display and buttons respond.
Create the main screen layout

Every Flutter app starts with a root widget that sets up the visual theme and navigation. You will replace the counter app template with a clean app shell for the OMR Grader.

  • In VS Code, open lib/main.dart from the Explorer sidebar.
  • Select all the existing code (Cmd+A on macOS or Ctrl+A on Windows) and delete it.
  • Paste the following code that sets up the app shell, the GraderScreen widget, and the state variables:
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:typed_data';
import 'omr_processor.dart';

void main() {
  runApp(const OmrGraderApp());
}

class OmrGraderApp extends StatelessWidget {
  const OmrGraderApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'OMR Grader',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: const GraderScreen(),
    );
  }
}

class GraderScreen extends StatefulWidget {
  const GraderScreen({super.key});

  @override
  State<GraderScreen> createState() => _GraderScreenState();
}

class _GraderScreenState extends State<GraderScreen> {
  // Image picker instance for accessing the gallery
  final ImagePicker _picker = ImagePicker();
  // Path to the selected image file
  String? _imagePath;
  // Raw bytes for displaying the original image
  Uint8List? _originalBytes;
  // Raw bytes for displaying the processed/annotated image
  Uint8List? _processedBytes;
  // Grading result object (from the OMR processor)
  GradeResult? _result;
  // Whether grading is currently in progress
  bool _isProcessing = false;

What does this code do?

The imports bring in Flutter's UI toolkit, the image_picker package for gallery access, Dart's typed data library for byte arrays, and a reference to omr_processor.dart (which you will create shortly as a placeholder).

OmrGraderApp is the root widget that wraps everything in a MaterialApp with a blue color scheme. GraderScreen is a StatefulWidget because the UI needs to update when images are loaded or grading completes.

The six state variables track the selected image path, the raw image bytes for display, the processed output, the grading result, and a processing flag that controls the loading spinner.

Implement image loading from gallery

The image_picker package handles opening the device gallery and returning the selected photo. You need a method that calls the picker, reads the file bytes, and updates state so the UI displays the image.

  • Below the state variable declarations (still inside the _GraderScreenState class), add the image loading method:
  Future<void> _loadImage() async {
    // Open the device gallery and let the user pick a photo
    final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
    if (image != null) {
      // Read the image file as bytes for display
      final bytes = await image.readAsBytes();
      setState(() {
        _imagePath = image.path;
        _originalBytes = bytes;
        // Reset previous results when a new image is loaded
        _processedBytes = null;
        _result = null;
      });
    }
  }

What does this code do?

  • pickImage(source: ImageSource.gallery) opens the phone's photo gallery and returns an XFile when the user selects a photo (or null if they cancel).
  • readAsBytes() converts the file into a Uint8List that Flutter's Image.memory widget can display directly.
  • setState() tells Flutter to rebuild the UI with the updated values. Previous results are cleared so stale data does not linger.

Will my phone ask for permission?

The first time you tap Load Image, Android will show a permission dialog asking to access photos. Grant the permission to continue. If you deny it, pickImage returns null and nothing happens.

Wire up the UI state

The grading method and the visual layout tie everything together. The Grade button calls the OMR processor (which you will build in later steps), and the build method arranges the buttons, images, and score card on screen.

  • Below the _loadImage method, add the grading trigger method:
  Future<void> _gradeSheet() async {
    if (_imagePath == null) return;

    setState(() {
      _isProcessing = true;
    });

    try {
      // Call the OMR processor (implemented in later steps)
      final result = gradeAnswerSheet(_imagePath!);
      setState(() {
        _result = result;
        _processedBytes = result.annotatedImage;
        _isProcessing = false;
      });
    } catch (e) {
      setState(() {
        _isProcessing = false;
      });
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Error: $e')),
        );
      }
    }
  }

What does this code do?

  • The method sets _isProcessing = true to show a loading spinner while the processor works.
  • gradeAnswerSheet() is the function you will build in Steps 3-5. For now, it will throw an error (since the processor is just a placeholder).
  • The try/catch block handles errors gracefully by showing a snackbar message instead of crashing.
  • Below _gradeSheet, add the build method that creates the visual layout, followed by a helper function. Then close the class with a final }:
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('OMR Grader'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            // Action buttons row
            Row(
              children: [
                Expanded(
                  child: ElevatedButton.icon(
                    onPressed: _loadImage,
                    icon: const Icon(Icons.photo_library),
                    label: const Text('Load Image'),
                  ),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: ElevatedButton.icon(
                    // Disabled when no image loaded or processing
                    onPressed: _imagePath != null && !_isProcessing
                        ? _gradeSheet
                        : null,
                    icon: const Icon(Icons.grading),
                    label: const Text('Grade'),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 16),
            // Loading spinner
            if (_isProcessing)
              const Center(child: CircularProgressIndicator()),
            // Original image display
            if (_originalBytes != null) ...[
              const Text('Original:', style: TextStyle(fontWeight: FontWeight.bold)),
              const SizedBox(height: 8),
              Image.memory(_originalBytes!, height: 200, fit: BoxFit.contain),
            ],
            // Processed image display
            if (_processedBytes != null) ...[
              const SizedBox(height: 16),
              const Text('Processed:', style: TextStyle(fontWeight: FontWeight.bold)),
              const SizedBox(height: 8),
              Image.memory(_processedBytes!, height: 200, fit: BoxFit.contain),
            ],
            // Score card with per-question breakdown
            if (_result != null) ...[
              const SizedBox(height: 16),
              Card(
                child: Padding(
                  padding: const EdgeInsets.all(16),
                  child: Column(
                    children: [
                      Text(
                        'Score: \${_result!.correctCount}/\${_result!.totalQuestions}',
                        style: const TextStyle(
                            fontSize: 24, fontWeight: FontWeight.bold),
                      ),
                      const Divider(),
                      // Loop through each question result
                      for (int i = 0; i < _result!.detectedAnswers.length; i++)
                        Padding(
                          padding: const EdgeInsets.symmetric(vertical: 2),
                          child: Row(
                            children: [
                              Text('Q\${i + 1}: '),
                              Text(
                                'Detected \${_choiceLetter(_result!.detectedAnswers[i])}',
                              ),
                              const Spacer(),
                              Icon(
                                _result!.isCorrect[i]
                                    ? Icons.check_circle
                                    : Icons.cancel,
                                color: _result!.isCorrect[i]
                                    ? Colors.green
                                    : Colors.red,
                              ),
                            ],
                          ),
                        ),
                    ],
                  ),
                ),
              ),
            ],
          ],
        ),
      ),
    );
  }

  // Convert a choice index (0-3) to a letter (A-D)
  String _choiceLetter(int index) {
    const letters = ['A', 'B', 'C', 'D'];
    if (index >= 0 && index < letters.length) return letters[index];
    return '?';
  }
}

What does the build method create?

  • A Row with two buttons: Load Image (always active) and Grade (disabled until an image is loaded).
  • A CircularProgressIndicator that appears only when _isProcessing is true.
  • Two image display areas (original and processed) that appear conditionally when bytes are available.
  • A Card with the score and a per-question breakdown (green check or red X for each).

Flutter's if inside the children list is a collection-if. It only adds those widgets when the condition is true, so the UI builds up progressively as the user loads and grades an image.

  • Now create the placeholder processor file. In the Explorer sidebar, right-click the lib folder and select New File.
  • Name the file omr_processor.dart.
  • Paste this placeholder code so the app compiles:
import 'dart:typed_data';

// Answer key: question index -> correct choice index (0=A, 1=B, 2=C, 3=D)
const Map<int, int> answerKey = {
  0: 1,
  1: 2,
  2: 0,
  3: 3,
  4: 1,
};

const int questionsCount = 5;
const int choicesCount = 4;

class GradeResult {
  final List<int> detectedAnswers;
  final List<bool> isCorrect;
  final int correctCount;
  final int totalQuestions;
  final Uint8List? annotatedImage;

  GradeResult({
    required this.detectedAnswers,
    required this.isCorrect,
    required this.correctCount,
    required this.totalQuestions,
    this.annotatedImage,
  });
}

// Placeholder - real implementation comes in Steps 3-5
GradeResult gradeAnswerSheet(String imagePath) {
  throw UnimplementedError('OMR processing not yet implemented');
}

Why create a placeholder?

main.dart imports omr_processor.dart and references GradeResult and gradeAnswerSheet. Without this file, Dart cannot compile the app at all.

The placeholder defines the class structure and function signature so everything compiles. Tapping the Grade button will show an error snackbar for now, which is expected. You will replace this placeholder with real OpenCV logic in the next steps.

  • Save both lib/main.dart and lib/omr_processor.dart.
  • In the terminal, run the app on your phone:
flutter run
  • Once the app launches, tap Load Image. Your phone's gallery opens.
  • Select any photo. It should appear in the app under the "Original:" label.

App not compiling or Load Image not working?

  • If you see a "Target URI doesn't exist" error for omr_processor.dart, confirm the file is inside the lib/ folder (not the project root).
  • If the gallery does not open, check that you granted the storage/photos permission when the dialog appeared. You can reset app permissions in your phone's Settings under Apps.
  • If you see a blank screen, make sure you deleted ALL the old counter app code before pasting the new code.

Still stuck? Help me debug why my Flutter OMR Grader app is not compiling or the image picker is not opening the gallery.

✔️ Awesome, I've got everything!

Great. Double check that you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete lib/main.dart file:

import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:typed_data';
import 'omr_processor.dart';

void main() {
  runApp(const OmrGraderApp());
}

class OmrGraderApp extends StatelessWidget {
  const OmrGraderApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'OMR Grader',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: const GraderScreen(),
    );
  }
}

class GraderScreen extends StatefulWidget {
  const GraderScreen({super.key});

  @override
  State<GraderScreen> createState() => _GraderScreenState();
}

class _GraderScreenState extends State<GraderScreen> {
  final ImagePicker _picker = ImagePicker();
  String? _imagePath;
  Uint8List? _originalBytes;
  Uint8List? _processedBytes;
  GradeResult? _result;
  bool _isProcessing = false;

  Future<void> _loadImage() async {
    final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
    if (image != null) {
      final bytes = await image.readAsBytes();
      setState(() {
        _imagePath = image.path;
        _originalBytes = bytes;
        _processedBytes = null;
        _result = null;
      });
    }
  }

  Future<void> _gradeSheet() async {
    if (_imagePath == null) return;

    setState(() {
      _isProcessing = true;
    });

    try {
      final result = gradeAnswerSheet(_imagePath!);
      setState(() {
        _result = result;
        _processedBytes = result.annotatedImage;
        _isProcessing = false;
      });
    } catch (e) {
      setState(() {
        _isProcessing = false;
      });
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Error: $e')),
        );
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('OMR Grader'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Row(
              children: [
                Expanded(
                  child: ElevatedButton.icon(
                    onPressed: _loadImage,
                    icon: const Icon(Icons.photo_library),
                    label: const Text('Load Image'),
                  ),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: ElevatedButton.icon(
                    onPressed: _imagePath != null && !_isProcessing
                        ? _gradeSheet
                        : null,
                    icon: const Icon(Icons.grading),
                    label: const Text('Grade'),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 16),
            if (_isProcessing)
              const Center(child: CircularProgressIndicator()),
            if (_originalBytes != null) ...[
              const Text('Original:', style: TextStyle(fontWeight: FontWeight.bold)),
              const SizedBox(height: 8),
              Image.memory(_originalBytes!, height: 200, fit: BoxFit.contain),
            ],
            if (_processedBytes != null) ...[
              const SizedBox(height: 16),
              const Text('Processed:', style: TextStyle(fontWeight: FontWeight.bold)),
              const SizedBox(height: 8),
              Image.memory(_processedBytes!, height: 200, fit: BoxFit.contain),
            ],
            if (_result != null) ...[
              const SizedBox(height: 16),
              Card(
                child: Padding(
                  padding: const EdgeInsets.all(16),
                  child: Column(
                    children: [
                      Text(
                        'Score: ${_result!.correctCount}/${_result!.totalQuestions}',
                        style: const TextStyle(
                            fontSize: 24, fontWeight: FontWeight.bold),
                      ),
                      const Divider(),
                      for (int i = 0; i < _result!.detectedAnswers.length; i++)
                        Padding(
                          padding: const EdgeInsets.symmetric(vertical: 2),
                          child: Row(
                            children: [
                              Text('Q${i + 1}: '),
                              Text(
                                'Detected ${_choiceLetter(_result!.detectedAnswers[i])}',
                              ),
                              const Spacer(),
                              Icon(
                                _result!.isCorrect[i]
                                    ? Icons.check_circle
                                    : Icons.cancel,
                                color: _result!.isCorrect[i]
                                    ? Colors.green
                                    : Colors.red,
                              ),
                            ],
                          ),
                        ),
                    ],
                  ),
                ),
              ),
            ],
          ],
        ),
      ),
    );
  }

  String _choiceLetter(int index) {
    const letters = ['A', 'B', 'C', 'D'];
    if (index >= 0 && index < letters.length) return letters[index];
    return '?';
  }
}

Your UI is wired up and ready. Next up, you will build the actual image processing engine using OpenCV to convert photos into binary images that reveal the filled bubbles.

Implement Image Preprocessing with OpenCV

Your app's UI is wired up and can load photos from the gallery. The buttons and image display areas are ready, but tapping Grade does nothing useful yet because the processing function doesn't exist.

In this step, you'll create the image processing engine that turns a colour photo into a clean black-and-white binary image. This preprocessing stage is what makes filled-in bubbles detectable by the computer.

In this step, get ready to:
  • Create the OMR processor file with the data model and answer key.
  • Build the image preprocessing pipeline using OpenCV (grayscale, blur, threshold).
  • Verify the thresholded output displays alongside the original photo.
Create the processor file with constants and data model

Before writing any image processing logic, you need a place to put it and a data structure to hold results. The GradeResult class will carry everything the UI needs: which answers were detected, which are correct, and the annotated image to display.

  • In VS Code's Explorer sidebar, right-click the lib folder inside your omr_grader project.
  • Select New File and name it omr_processor.dart.
  • Add the imports, answer key, constants, and GradeResult class by pasting this code:
import 'dart:typed_data';
import 'package:opencv_dart/opencv.dart' as cv;

// Answer key: question index -> correct choice index (0=A, 1=B, 2=C, 3=D)
const Map<int, int> answerKey = {
  0: 1,
  1: 2,
  2: 0,
  3: 3,
  4: 1,
};

// Sheet layout configuration
const int questionsCount = 5;
const int choicesCount = 4;

// Holds the grading results for the UI to display
class GradeResult {
  final List<int> detectedAnswers;
  final List<bool> isCorrect;
  final int correctCount;
  final int totalQuestions;
  final Uint8List? annotatedImage;

  GradeResult({
    required this.detectedAnswers,
    required this.isCorrect,
    required this.correctCount,
    required this.totalQuestions,
    this.annotatedImage,
  });
}

What does this code do?

The answerKey map stores the correct answer for each question. For example, question 0's correct answer is index 1 (which means B). You'll compare detected answers against this key later.

The GradeResult class bundles everything the UI needs into one object. The annotatedImage field will hold the processed image as PNG bytes so Flutter can display it directly.

Why import opencv_dart as cv?

The as cv prefix keeps OpenCV function calls namespaced (like cv.imread). This prevents collisions with Flutter's own classes and makes it clear which calls are image processing operations.

Build the preprocessing function with grayscale, blur, and threshold

The core of Optical Mark Recognition is converting a noisy colour photo into a clean binary image where filled bubbles stand out as bright white regions. This takes three stages: grayscale conversion, Gaussian blur to reduce noise, and Otsu's thresholding to separate foreground from background.

  • In omr_processor.dart, add the gradeAnswerSheet function below the GradeResult class:
GradeResult gradeAnswerSheet(String imagePath) {
  // Load the image from disk as a colour Mat
  final img = cv.imread(imagePath);
  // Convert to single-channel grayscale
  final gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
  // Smooth out noise so small specks don't confuse thresholding
  final blurred = cv.gaussianBlur(gray, (5, 5), 0);
  // Otsu's threshold: automatically picks the best cutoff value
  // THRESH_BINARY_INV makes filled areas white and background black
  final (_, thresh) = cv.threshold(
    blurred,
    0,
    255,
    cv.THRESH_BINARY_INV | cv.THRESH_OTSU,
  );

  // Encode the thresholded image as PNG bytes for display
  final (_, annotatedBytes) = cv.imencode('.png', thresh);

  // Release native memory for each Mat
  gray.dispose();
  blurred.dispose();
  thresh.dispose();
  img.dispose();

  return GradeResult(
    detectedAnswers: [],
    isCorrect: [],
    correctCount: 0,
    totalQuestions: questionsCount,
    annotatedImage: annotatedBytes,
  );
}

What does this pipeline do?

  • cv.imread loads the photo from the file path into a Mat (OpenCV's image container).
  • cv.cvtColor with COLOR_BGR2GRAY strips colour information down to a single brightness channel.
  • cv.gaussianBlur with a 5x5 kernel smooths out camera noise and paper texture.
  • cv.threshold with THRESH_BINARY_INV | THRESH_OTSU automatically picks a cutoff brightness and inverts the result so filled marks become white.
  • cv.imencode converts the processed Mat into PNG bytes that Flutter's Image.memory widget can display.
  • .dispose() frees the native memory each Mat holds. Skipping this causes memory leaks on mobile.

What is image thresholding?

Thresholding converts a grayscale image into pure black and white. Every pixel above a cutoff value becomes one colour, and every pixel below becomes the other. Otsu's method automatically calculates the best cutoff by analyzing the image's histogram.

The BINARY_INV flag inverts the output so dark pencil marks (originally low pixel values) become white. This makes filled bubbles easy to detect as bright regions in later steps.

  • Save omr_processor.dart.

Seeing red underlines in VS Code?

If VS Code shows errors on the cv. calls, the Dart analyzer may not have finished indexing the opencv_dart package. Wait a few seconds for the analysis to complete. If errors persist, check that your import line matches package:opencv_dart/opencv.dart exactly.

help me fix import errors in my omr_processor.dart file

Run the app and verify the preprocessed output

The UI in main.dart already calls gradeAnswerSheet and displays the returned annotatedImage bytes in the Processed section. All you need to do is rebuild and test.

  • In your VS Code terminal, hot-restart the app by pressing R (capital R) in the terminal where flutter run is active.
  • On your phone, tap Load Image and select a photo of a bubble sheet (a printed 5-question sheet with some bubbles filled in).
  • Tap Grade.

You should see the original colour photo at the top and a stark black-and-white version below it. Filled bubbles appear as white blobs on a black background.

No bubble sheet handy?

Search for "OMR bubble sheet 5 questions" on your phone's browser and take a screenshot. Then use your phone's photo editor to fill in some circles with a dark colour before loading the screenshot in the app.

App crashes when tapping Grade?

If you see a red error screen or a SnackBar error message, check that omr_processor.dart is saved inside the lib folder (not the project root). The import in main.dart expects it at lib/omr_processor.dart.

Also confirm the function name is exactly gradeAnswerSheet (camelCase, no underscore). The UI calls this function by name.

help me debug why my OMR app crashes when I tap Grade

✔️ Awesome, I've got everything!

Great. Double check you've saved omr_processor.dart and confirmed both images display on your phone.

ⓧ I'd like to double check the full code

Here is the complete lib/omr_processor.dart file at the end of this step:

import 'dart:typed_data';
import 'package:opencv_dart/opencv.dart' as cv;

const Map<int, int> answerKey = {
  0: 1,
  1: 2,
  2: 0,
  3: 3,
  4: 1,
};

const int questionsCount = 5;
const int choicesCount = 4;

class GradeResult {
  final List<int> detectedAnswers;
  final List<bool> isCorrect;
  final int correctCount;
  final int totalQuestions;
  final Uint8List? annotatedImage;

  GradeResult({
    required this.detectedAnswers,
    required this.isCorrect,
    required this.correctCount,
    required this.totalQuestions,
    this.annotatedImage,
  });
}

GradeResult gradeAnswerSheet(String imagePath) {
  final img = cv.imread(imagePath);
  final gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
  final blurred = cv.gaussianBlur(gray, (5, 5), 0);
  final (_, thresh) = cv.threshold(
    blurred,
    0,
    255,
    cv.THRESH_BINARY_INV | cv.THRESH_OTSU,
  );

  final (_, annotatedBytes) = cv.imencode('.png', thresh);

  gray.dispose();
  blurred.dispose();
  thresh.dispose();
  img.dispose();

  return GradeResult(
    detectedAnswers: [],
    isCorrect: [],
    correctCount: 0,
    totalQuestions: questionsCount,
    annotatedImage: annotatedBytes,
  );
}

The lib/main.dart file is unchanged from the previous step.

Your preprocessing pipeline is working. You've turned a messy phone photo into a clean binary image where filled bubbles are bright white shapes. Next up, you'll use contour detection to find and isolate those bubble shapes so you can read which ones are marked.

Detect Bubble Contours

Your app can now turn a photo into a clean black-and-white image where filled bubbles stand out as bright white regions.

The next challenge is teaching it to find those bubbles automatically. In this step, you will use contour detection to locate every bubble shape in the thresholded image, filter out noise, and organize the bubbles into a 5-row by 4-column grid that matches the answer sheet layout.

In this step, get ready to:
  • Find all contours in the thresholded image and filter them to isolate bubble shapes.
  • Sort detected bubbles into a grid of 5 rows (questions) and 4 columns (choices A-D).
  • Draw bounding rectangles on detected bubbles and display the annotated image for verification.
Find contours and filter for bubble shapes

A contour is a continuous curve that outlines a shape in an image. OpenCV's findContours function scans the thresholded image and returns every distinct white shape it finds.

The problem is that it finds everything, including text, stray marks, and edges. You need to filter by shape: real bubbles are roughly circular (aspect ratio near 1.0) and within a predictable size range.

  • In omr_processor.dart, find this line:
  final (_, annotatedBytes) = cv.imencode('.png', thresh);
  • Delete that line.
  • In its place, add this contour detection and filtering code:
  // Find all contours in the binary image
  final (contours, _) = cv.findContours(
    thresh,
    cv.RETR_EXTERNAL,
    cv.CHAIN_APPROX_SIMPLE,
  );

  // Filter contours to keep only bubble-shaped ones
  final bubbleContours = <(cv.VecPoint, cv.Rect)>[];
  for (int i = 0; i < contours.length; i++) {
    final contour = contours[i];
    final rect = cv.boundingRect(contour);
    final aspectRatio = rect.width / rect.height.toDouble();
    final area = cv.contourArea(contour);

    // Bubbles are roughly circular (aspect ratio ~1.0) and within a size range
    if (aspectRatio >= 0.8 &&
        aspectRatio <= 1.2 &&
        area > 100 &&
        area < 5000) {
      bubbleContours.add((contour.clone(), rect));
    }
  }

What does this code do?

cv.findContours scans the thresholded image and returns a list of every white shape's boundary points. RETR_EXTERNAL tells it to only find outer contours (ignore shapes inside other shapes).

The filtering loop checks two properties of each contour's bounding rectangle. First, aspect ratio between 0.8 and 1.2 means the shape is roughly as wide as it is tall (a circle). Second, area between 100 and 5000 pixels rules out tiny noise specks and large non-bubble shapes.

contour.clone() is critical here. Contour references from findContours point to native memory that can be garbage collected. Cloning creates an independent copy you can safely store.

  • Save omr_processor.dart.
  • You will see a compile error since the annotatedBytes variable no longer exists. That is expected. You will fix it in substep 3.

Seeing a different error?

If you see an error about findContours not being found, make sure your import at the top of the file is import 'package:opencv_dart/opencv.dart' as cv; and that you ran flutter pub get after adding opencv_dart to your dependencies.

Help me fix an import error with opencv_dart findContours

Sort bubbles into a question grid

Finding bubble shapes is only half the problem. You also need to know which bubble belongs to which question and which choice (A, B, C, or D).

The strategy is to sort all bubbles from top to bottom by their y-coordinate, then group them into rows. Each row represents one question, and within a row, sorting left to right gives you A through D.

  • In omr_processor.dart, add this sorting and grouping code directly below the filtering loop you just wrote:
  // Sort all bubble contours from top to bottom
  bubbleContours.sort((a, b) => a.$2.y.compareTo(b.$2.y));

  // Group bubbles into rows (one row per question)
  final rows = <List<(cv.VecPoint, cv.Rect)>>[];
  List<(cv.VecPoint, cv.Rect)> currentRow = [];
  int? lastY;

  for (final bubble in bubbleContours) {
    // If this bubble is close in y to the last one, it's the same row
    if (lastY == null || (bubble.$2.y - lastY).abs() < bubble.$2.height) {
      currentRow.add(bubble);
    } else {
      // Start a new row, but only keep the previous row if it has exactly 4 bubbles
      if (currentRow.length == choicesCount) {
        rows.add(List.from(currentRow));
      }
      currentRow = [bubble];
    }
    lastY = bubble.$2.y;
  }
  // Don't forget the last row
  if (currentRow.length == choicesCount) {
    rows.add(List.from(currentRow));
  }

  // Sort each row left-to-right to get choices A, B, C, D in order
  for (final row in rows) {
    row.sort((a, b) => a.$2.x.compareTo(b.$2.x));
  }

What does this code do?

The first sort orders all detected bubbles by their vertical position (y-coordinate). Bubbles near the top of the image come first.

The grouping loop walks through sorted bubbles and checks whether each one is vertically close to the previous. If the y-distance is less than the bubble's own height, they belong to the same row. When a gap appears, a new row starts.

Only rows with exactly choicesCount (4) bubbles are kept. This discards partial detections from stray marks. Finally, sorting each row by x-coordinate ensures the bubbles map to A, B, C, D from left to right.

  • Save omr_processor.dart. The compile error will persist until you add the next chunk.
Draw bounding rectangles to verify detection

Before you trust the detection logic, you need visual proof that the correct 20 bubbles were found. Drawing a colored rectangle around each detected bubble lets you see at a glance whether the grid is accurate.

  • Below the sorting code you just added, add this annotation and encoding code:
  // Draw blue rectangles around each detected bubble for visual verification
  final annotated = img.clone();
  for (final row in rows) {
    for (final bubble in row) {
      final rect = bubble.$2;
      cv.rectangle(
        annotated,
        cv.Rect(rect.x, rect.y, rect.width, rect.height),
        cv.Scalar(255, 0, 0),
        thickness: 2,
      );
    }
  }

  // Encode the annotated image to PNG bytes for display
  final (_, annotatedBytes) = cv.imencode('.png', annotated);

What does this code do?

img.clone() creates a copy of the original color image so you can draw on it without modifying the source.

cv.rectangle draws a blue rectangle (Scalar(255, 0, 0) in BGR color order) around each bubble's bounding box. Finally, cv.imencode converts the annotated image to PNG bytes that your Flutter UI can display.

  • Now update the dispose section at the bottom of the function. Find these lines:
  gray.dispose();
  blurred.dispose();
  thresh.dispose();
  img.dispose();
  • Add annotated.dispose(); after img.dispose(); so the updated block looks like this:
  gray.dispose();
  blurred.dispose();
  thresh.dispose();
  img.dispose();
  annotated.dispose();
  • Save omr_processor.dart.
  • Run the app on your phone by pressing F5 in VS Code or running:
flutter run
  • Load a bubble sheet photo and tap Grade. You should see the original photo in the "Processed" area with blue rectangles drawn around each detected bubble. Confirm you see 20 rectangles arranged in a 5x4 grid.

Not seeing 20 rectangles?

If you see too few rectangles, your bubbles may be too small or too large for the area filter. Try adjusting the area > 100 && area < 5000 values. Print bubbleContours.length to see how many contours pass the filter.

If you see too many rectangles (stray marks being detected), ensure your bubble sheet photo has good contrast and minimal handwriting outside the bubble areas.

If the app crashes with a memory error, confirm you added annotated.dispose() to the dispose section and that you are calling .clone() on contours before storing them.

Help me debug why my bubble detection isn't finding the right number of contours

✔️ Awesome, I've got everything!

Great. Double check you have saved omr_processor.dart and the annotated image shows 20 blue rectangles on your phone.

ⓧ I'd like to double check the full code

Here is the full omr_processor.dart at this point:

import 'dart:typed_data';
import 'package:opencv_dart/opencv.dart' as cv;

const Map<int, int> answerKey = {
  0: 1,
  1: 2,
  2: 0,
  3: 3,
  4: 1,
};

const int questionsCount = 5;
const int choicesCount = 4;

class GradeResult {
  final List<int> detectedAnswers;
  final List<bool> isCorrect;
  final int correctCount;
  final int totalQuestions;
  final Uint8List? annotatedImage;

  GradeResult({
    required this.detectedAnswers,
    required this.isCorrect,
    required this.correctCount,
    required this.totalQuestions,
    this.annotatedImage,
  });
}

GradeResult gradeAnswerSheet(String imagePath) {
  final img = cv.imread(imagePath);
  final gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
  final blurred = cv.gaussianBlur(gray, (5, 5), 0);
  final (_, thresh) = cv.threshold(
    blurred,
    0,
    255,
    cv.THRESH_BINARY_INV | cv.THRESH_OTSU,
  );

  final (contours, _) = cv.findContours(
    thresh,
    cv.RETR_EXTERNAL,
    cv.CHAIN_APPROX_SIMPLE,
  );

  final bubbleContours = <(cv.VecPoint, cv.Rect)>[];
  for (int i = 0; i < contours.length; i++) {
    final contour = contours[i];
    final rect = cv.boundingRect(contour);
    final aspectRatio = rect.width / rect.height.toDouble();
    final area = cv.contourArea(contour);

    if (aspectRatio >= 0.8 &&
        aspectRatio <= 1.2 &&
        area > 100 &&
        area < 5000) {
      bubbleContours.add((contour.clone(), rect));
    }
  }

  bubbleContours.sort((a, b) => a.$2.y.compareTo(b.$2.y));

  final rows = <List<(cv.VecPoint, cv.Rect)>>[];
  List<(cv.VecPoint, cv.Rect)> currentRow = [];
  int? lastY;

  for (final bubble in bubbleContours) {
    if (lastY == null || (bubble.$2.y - lastY).abs() < bubble.$2.height) {
      currentRow.add(bubble);
    } else {
      if (currentRow.length == choicesCount) {
        rows.add(List.from(currentRow));
      }
      currentRow = [bubble];
    }
    lastY = bubble.$2.y;
  }
  if (currentRow.length == choicesCount) {
    rows.add(List.from(currentRow));
  }

  for (final row in rows) {
    row.sort((a, b) => a.$2.x.compareTo(b.$2.x));
  }

  final annotated = img.clone();
  for (final row in rows) {
    for (final bubble in row) {
      final rect = bubble.$2;
      cv.rectangle(
        annotated,
        cv.Rect(rect.x, rect.y, rect.width, rect.height),
        cv.Scalar(255, 0, 0),
        thickness: 2,
      );
    }
  }

  final (_, annotatedBytes) = cv.imencode('.png', annotated);

  gray.dispose();
  blurred.dispose();
  thresh.dispose();
  img.dispose();
  annotated.dispose();

  return GradeResult(
    detectedAnswers: [],
    isCorrect: [],
    correctCount: 0,
    totalQuestions: questionsCount,
    annotatedImage: annotatedBytes,
  );
}

Your app can now find and organize all 20 bubbles on the sheet. Next up, you will read which bubbles are actually filled in and calculate a score.

Read Answers and Calculate Score

In the previous step, you confirmed that your app can detect and locate all 20 bubbles on the answer sheet. Now it's time to actually read which bubbles are filled in and determine the score.

The key insight is pixel density. A filled-in bubble has far more white pixels (in the thresholded binary image) than an empty one. By counting pixels inside each bubble's contour, you can determine which choice the student marked for each question.

In this step, get ready to:
  • Read marked answers using pixel density analysis on each bubble's contour mask.
  • Compare detected answers against a hardcoded answer key and calculate the total score.
  • Display the final score with a per-question correct/incorrect breakdown.
Read marked answers with pixel density analysis

For each question row, you need to check all 4 bubble contours and find which one has the most filled-in pixels. You do this by creating a mask for each bubble, applying it to the thresholded image, and counting the white pixels inside.

  • In omr_processor.dart, find the annotation loop section that currently draws blue rectangles on all bubbles. It starts with:
  final annotated = img.clone();
  for (final row in rows) {
    for (final bubble in row) {
      final rect = bubble.$2;
      cv.rectangle(
        annotated,
        cv.Rect(rect.x, rect.y, rect.width, rect.height),
        cv.Scalar(255, 0, 0),
        thickness: 2,
      );
    }
  }
  • Replace that entire section (from final annotated = img.clone(); through the closing } of the outer for loop) with the following pixel density analysis code:
  final detectedAnswers = <int>[];
  final annotated = img.clone();

  for (int q = 0; q < rows.length && q < questionsCount; q++) {
    int maxPixels = 0;
    int markedIndex = 0;

    for (int c = 0; c < rows[q].length && c < choicesCount; c++) {
      final contour = rows[q][c].$1;
      // Create a blank mask the same size as the thresholded image
      final mask = cv.Mat.zeros(thresh.rows, thresh.cols, cv.MatType.CV_8UC1);
      // Fill the bubble's contour area with white on the mask
      cv.drawContours(mask, cv.VecVecPoint.fromList([contour]), 0, cv.Scalar(255, 255, 255), thickness: cv.FILLED);
      // Apply the mask to isolate just this bubble's pixels
      final masked = cv.bitwiseAND(thresh, thresh, mask: mask);
      // Count how many white pixels are inside this bubble
      final pixels = masked.countNoneZero;

      if (pixels > maxPixels) {
        maxPixels = pixels;
        markedIndex = c;
      }

      mask.dispose();
      masked.dispose();
    }

    detectedAnswers.add(markedIndex);

    // Draw colored rectangle: green for correct, red for incorrect
    final markedRect = rows[q][markedIndex].$2;
    final isCorrect = answerKey[q] == markedIndex;
    final color = isCorrect
        ? cv.Scalar(0, 255, 0)
        : cv.Scalar(0, 0, 255);
    cv.rectangle(
      annotated,
      cv.Rect(markedRect.x, markedRect.y, markedRect.width, markedRect.height),
      color,
      thickness: 2,
    );
  }

What does this code do?

This loop iterates through each question row and reads which bubble the student marked:

  • cv.Mat.zeros creates a blank black image the same size as the thresholded image. This becomes the mask for one bubble.
  • cv.drawContours with cv.FILLED fills the bubble's contour area with white on the mask. Only pixels inside this bubble will be visible.
  • cv.bitwiseAND applies the mask to the thresholded image. The result contains only the white pixels inside that one bubble.
  • masked.countNoneZero counts those white pixels. A filled bubble has many more white pixels than an empty one.
  • The bubble with the highest pixel count is the student's marked answer for that question.

You won't see a change yet. The next chunk adds the scoring summary and updated return statement.

Calculate the total score

Now that you have the detected answers, you need to formally compare each one against the answer key and tally up the score. The answerKey map at the top of your file defines the correct choice (0-3 for A-D) for each question.

  • In the same file, find the section below your new loop that starts with final (_, annotatedBytes) = cv.imencode('.png', annotated); and continues through the return GradeResult(...) at the end of the function:
  final (_, annotatedBytes) = cv.imencode('.png', annotated);

  gray.dispose();
  blurred.dispose();
  thresh.dispose();
  img.dispose();
  annotated.dispose();

  return GradeResult(
    detectedAnswers: [],
    isCorrect: [],
    correctCount: 0,
    totalQuestions: questionsCount,
    annotatedImage: annotatedBytes,
  );
  • Replace that entire section with the following scoring logic and updated return statement:
  // Build the score summary by comparing each answer to the key
  final isCorrectList = <bool>[];
  int correctCount = 0;
  for (int i = 0; i < detectedAnswers.length; i++) {
    final correct = answerKey[i] == detectedAnswers[i];
    isCorrectList.add(correct);
    if (correct) correctCount++;
  }

  final (_, annotatedBytes) = cv.imencode('.png', annotated);

  // Clean up native memory
  gray.dispose();
  blurred.dispose();
  thresh.dispose();
  img.dispose();
  annotated.dispose();

  return GradeResult(
    detectedAnswers: detectedAnswers,
    isCorrect: isCorrectList,
    correctCount: correctCount,
    totalQuestions: questionsCount,
    annotatedImage: annotatedBytes,
  );

What does this code do?

This section iterates through the detectedAnswers list and compares each one against answerKey. If the detected answer index matches the key, it counts as correct.

The GradeResult now returns real data instead of empty lists. Your UI in main.dart already knows how to display this data as a score card with per-question icons.

  • Save omr_processor.dart.

Seeing a compile error?

  • Make sure you deleted the old for loop that drew blue rectangles. Having both the old loop and the new one will cause a duplicate variable error on annotated.
  • Check that final detectedAnswers = <int>[]; appears before final annotated = img.clone();. The scoring section references this list.
  • Confirm the old return GradeResult(detectedAnswers: [], ...) is fully replaced. Having two return statements will cause an unreachable code error.

Help me fix a compile error in omr_processor.dart after adding pixel density analysis.

Run the app and verify grading works

Your UI already has a score card that displays the results. Now that gradeAnswerSheet returns real data, tapping Grade will show the actual score.

  • Run the app on your phone by pressing F5 in VS Code (or run flutter run in the terminal).
  • Tap Load Image and select a photo of a filled-in bubble sheet from your gallery.
  • Tap Grade.

You should see a score card appear below the processed image showing "Score: X/5" with green checkmarks for correct answers and red X icons for incorrect ones.

What should I see?

The processed image now shows colored rectangles instead of blue ones. Green rectangles mark correctly answered questions. Red rectangles mark incorrect ones.

Below the image, a card displays the total score and a per-question breakdown showing which choice was detected (A, B, C, or D) alongside a check or X icon.

Score showing 0/5 or wrong answers?

  • The answerKey map uses 0-indexed values (0=A, 1=B, 2=C, 3=D). Make sure your test bubble sheet matches these expected answers: Q1=B, Q2=C, Q3=A, Q4=D, Q5=B.
  • If detection finds the wrong bubbles, the issue is likely in the contour filtering from the previous step. Check that exactly 20 bubbles are detected.
  • Poor lighting or low contrast in the photo can cause incorrect pixel counts. Try a cleaner photo with dark, fully filled bubbles.

Help me debug why my OMR grader is detecting wrong answers.

✔️ Awesome, I've got everything!

Great. Double check you have saved omr_processor.dart and confirmed the score displays correctly on your phone.

ⓧ I'd like to double check the full code

Here is the complete omr_processor.dart file at this point:

import 'dart:typed_data';
import 'package:opencv_dart/opencv.dart' as cv;

const Map<int, int> answerKey = {
  0: 1,
  1: 2,
  2: 0,
  3: 3,
  4: 1,
};

const int questionsCount = 5;
const int choicesCount = 4;

class GradeResult {
  final List<int> detectedAnswers;
  final List<bool> isCorrect;
  final int correctCount;
  final int totalQuestions;
  final Uint8List? annotatedImage;

  GradeResult({
    required this.detectedAnswers,
    required this.isCorrect,
    required this.correctCount,
    required this.totalQuestions,
    this.annotatedImage,
  });
}

GradeResult gradeAnswerSheet(String imagePath) {
  final img = cv.imread(imagePath);
  final gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
  final blurred = cv.gaussianBlur(gray, (5, 5), 0);
  final (_, thresh) = cv.threshold(
    blurred,
    0,
    255,
    cv.THRESH_BINARY_INV | cv.THRESH_OTSU,
  );

  final (contours, _) = cv.findContours(
    thresh,
    cv.RETR_EXTERNAL,
    cv.CHAIN_APPROX_SIMPLE,
  );

  final bubbleContours = <(cv.VecPoint, cv.Rect)>[];
  for (int i = 0; i < contours.length; i++) {
    final contour = contours[i];
    final rect = cv.boundingRect(contour);
    final aspectRatio = rect.width / rect.height.toDouble();
    final area = cv.contourArea(contour);

    if (aspectRatio >= 0.8 &&
        aspectRatio <= 1.2 &&
        area > 100 &&
        area < 5000) {
      bubbleContours.add((contour.clone(), rect));
    }
  }

  bubbleContours.sort((a, b) => a.$2.y.compareTo(b.$2.y));

  final rows = <List<(cv.VecPoint, cv.Rect)>>[];
  List<(cv.VecPoint, cv.Rect)> currentRow = [];
  int? lastY;

  for (final bubble in bubbleContours) {
    if (lastY == null || (bubble.$2.y - lastY).abs() < bubble.$2.height) {
      currentRow.add(bubble);
    } else {
      if (currentRow.length == choicesCount) {
        rows.add(List.from(currentRow));
      }
      currentRow = [bubble];
    }
    lastY = bubble.$2.y;
  }
  if (currentRow.length == choicesCount) {
    rows.add(List.from(currentRow));
  }

  for (final row in rows) {
    row.sort((a, b) => a.$2.x.compareTo(b.$2.x));
  }

  final detectedAnswers = <int>[];
  final annotated = img.clone();

  for (int q = 0; q < rows.length && q < questionsCount; q++) {
    int maxPixels = 0;
    int markedIndex = 0;

    for (int c = 0; c < rows[q].length && c < choicesCount; c++) {
      final contour = rows[q][c].$1;
      final mask = cv.Mat.zeros(thresh.rows, thresh.cols, cv.MatType.CV_8UC1);
      cv.drawContours(mask, cv.VecVecPoint.fromList([contour]), 0, cv.Scalar(255, 255, 255), thickness: cv.FILLED);
      final masked = cv.bitwiseAND(thresh, thresh, mask: mask);
      final pixels = masked.countNoneZero;

      if (pixels > maxPixels) {
        maxPixels = pixels;
        markedIndex = c;
      }

      mask.dispose();
      masked.dispose();
    }

    detectedAnswers.add(markedIndex);

    final markedRect = rows[q][markedIndex].$2;
    final isCorrect = answerKey[q] == markedIndex;
    final color = isCorrect
        ? cv.Scalar(0, 255, 0)
        : cv.Scalar(0, 0, 255);
    cv.rectangle(
      annotated,
      cv.Rect(markedRect.x, markedRect.y, markedRect.width, markedRect.height),
      color,
      thickness: 2,
    );
  }

  final isCorrectList = <bool>[];
  int correctCount = 0;
  for (int i = 0; i < detectedAnswers.length; i++) {
    final correct = answerKey[i] == detectedAnswers[i];
    isCorrectList.add(correct);
    if (correct) correctCount++;
  }

  final (_, annotatedBytes) = cv.imencode('.png', annotated);

  gray.dispose();
  blurred.dispose();
  thresh.dispose();
  img.dispose();
  annotated.dispose();

  return GradeResult(
    detectedAnswers: detectedAnswers,
    isCorrect: isCorrectList,
    correctCount: correctCount,
    totalQuestions: questionsCount,
    annotatedImage: annotatedBytes,
  );
}

Secret mission

Add Camera Capture

Your OMR grader works with gallery photos, but a real grading app needs to capture sheets on the spot. In this mission, you'll add a live camera button and a threshold slider so you can photograph and grade bubble sheets in any lighting condition.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine and phone. There are no cloud services or ongoing costs to worry about.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • omr_grader Flutter project folder (local files).
  • Flutter SDK installed at C:\Flutter.
  • Android Studio and Android SDK.
  • Git for Windows.
  • Flutter extension in VS Code.

Keep everything running

No action needed. Choose this if you plan to continue building on this OMR scanner or want to start Project 2 (PDF sheet generation) next.

  • Your omr_grader project stays on your machine, ready to open in VS Code at any time.
  • Flutter SDK, Android Studio, and Git remain installed and usable for future Flutter projects.
  • The app you built stays installed on your Android phone until you manually uninstall it.

Pause - I'll come back to this later

Free up phone resources while keeping your project files intact.

  • Disconnect your phone from USB. You can re-enable USB Debugging later when you return.
  • Close VS Code and Android Studio to free up memory.
  • Your project files, Flutter SDK, and all installed tools remain on your machine for when you come back.

Delete - I don't want to use this again

Remove all project resources and development tools from your machine.

  • Delete the omr_grader project folder from your Desktop by running this in PowerShell:
  • Delete the Flutter SDK by removing the C:\Flutter folder. Run this in PowerShell:
Remove-Item -Recurse -Force -Path "C:\Flutter"
  • Remove Flutter from your system PATH. Open Settings, search for Edit environment variables for your account, select Path, click Edit, and delete the entry pointing to C:\Flutter\bin.
  • Uninstall Android Studio. Open Settings, then Apps, then Apps & features. Find Android Studio and click Uninstall.
  • Uninstall Git for Windows. In the same Apps & features page, find Git and click Uninstall.
  • Remove the Flutter extension from VS Code. Open VS Code, click the Extensions icon in the left sidebar, find the Flutter extension, and click Uninstall.
  • Uninstall the OMR Grader app from your Android phone. Long-press the app icon and select Uninstall.

Nice Work!

Nice Work!

Nice work! You just built an Android app that uses computer vision to automatically grade bubble sheet answer forms from a photo.

You've learned how to:

  • Build a Flutter Android app from scratch with OpenCV integration for on-device image processing.
  • Implement a complete OMR pipeline including grayscale conversion, thresholding, contour detection, and pixel density analysis to identify filled bubbles.
  • Create a grading system that compares detected answers against a stored answer key and displays a scored breakdown in the app UI.
  • Add live camera capture with a threshold sensitivity slider so you can photograph and grade bubble sheets under different lighting conditions (Secret Mission).

Ready to quiz yourself?