Lung Cancer Classification API - Flutter Integration Guidelines
Complete guide for integrating the Grad-CAM backend API into your Flutter application.
Table of Contents
- API Overview
- Base URL & Configuration
- Endpoints
- Request/Response Formats
- Image Handling
- Error Handling
- Code Examples
- UI Integration
- Best Practices
- Troubleshooting
API Overview
This API provides:
- YOLO Tumor Detection: Detects lung tumors in CT scan images
- DenseNet121 Classification: Classifies detected tumors into 4 cancer types
- Grad-CAM Visualization: Explains model predictions with attention heatmaps
- CT Scan Validation: Validates input is a medical image (not a color photo)
Supported Cancer Classes
0. Adenocarcinoma (Class A)
1. Small Cell (Class B)
2. Large Cell (Class E)
3. Squamous Cell (Class G)
Base URL & Configuration
Development
Base URL: http://localhost:5001
Production (Railway Deployment)
Base URL: https://your-railway-app.up.railway.app
Environment Configuration
class ApiConfig {
static const String baseUrl = 'http://localhost:5001'; // Change for production
static const int timeout = 120; // seconds (image processing takes time)
static const String apiVersion = 'v1.0.0';
}
Endpoints
1. Health Check
GET /health
Check if API is running and models are loaded.
Response:
{
"status": "healthy",
"model_loaded": true
}
2. Validate CT Scan
POST /validate-ct
Validate if uploaded image is a CT scan (grayscale check).
Request:
Content-Type: multipart/form-data
Body:
- file: <image_file>
Response (Valid CT):
{
"is_ct_scan": true,
"color_score": 3.52,
"message": "Valid CT scan - grayscale image detected"
}
Response (Invalid - Color Image):
{
"is_ct_scan": false,
"color_score": 45.2,
"message": "NOT a CT scan - color image detected (color_score=45.2)"
}
Color Score Interpretation:
< 6.0: Valid CT scan (grayscale)>= 6.0: Not a CT scan (color image detected)
3. Analyze (YOLO + Classification + Grad-CAM)
POST /analyze
Complete pipeline: detect tumors β classify β generate heatmaps.
Request:
Content-Type: multipart/form-data
Body:
- file: <image_file>
Response (With Tumors):
{
"success": true,
"tumors_detected": 1,
"detection_image": "base64_encoded_image_with_boxes",
"heatmap_image": "base64_encoded_heatmap",
"detections": [
{
"tumor_id": 1,
"bbox": [161, 319, 225, 390],
"bbox_with_padding": [149, 305, 237, 404],
"prediction": "Small Cell (Class B)",
"confidence": 44.62,
"all_confidences": {
"Adenocarcinoma (Class A)": 11.07,
"Small Cell (Class B)": 44.62,
"Large Cell (Class E)": 25.52,
"Squamous Cell (Class G)": 18.79
},
"crop_image": "base64_encoded_crop",
"heatmap_image": "base64_encoded_heatmap_of_crop"
}
]
}
Response (No Tumors):
{
"success": true,
"tumors_detected": 0,
"detections": [],
"detection_image": "base64_encoded_original_image",
"message": "No tumors detected in this image"
}
Response (Error):
{
"error": "Could not load image"
}
Request/Response Formats
Multipart Form Data (Image Upload)
// Example using http package
var request = http.MultipartRequest('POST', Uri.parse('$baseUrl/analyze'));
request.files.add(
http.MultipartFile.fromBytes(
'file',
imageBytes,
filename: 'scan.jpg',
),
);
Response Image Decoding
All images in responses are base64-encoded JPEG.
// Decode base64 to Image widget
Image.memory(
base64Decode(response['detection_image']),
fit: BoxFit.contain,
)
Image Handling
Supported Formats
- JPEG (.jpg, .jpeg)
- PNG (.png)
- Recommended: JPEG (faster processing)
Image Size Recommendations
- Minimum: 256x256 pixels
- Optimal: 512x512 pixels
- Maximum: 2048x2048 pixels (will be resized internally)
Image Acquisition
From Camera
import 'package:image_picker/image_picker.dart';
final picker = ImagePicker();
final pickedFile = await picker.pickImage(source: ImageSource.camera);
if (pickedFile != null) {
final bytes = await pickedFile.readAsBytes();
// Use bytes for upload
}
From Gallery
final pickedFile = await picker.pickImage(source: ImageSource.gallery);
if (pickedFile != null) {
final bytes = await pickedFile.readAsBytes();
// Use bytes for upload
}
From File
import 'dart:io';
File imageFile = File('/path/to/image.jpg');
final bytes = await imageFile.readAsBytes();
// Use bytes for upload
Base64 Image Display
import 'dart:convert';
// Decode and display
Widget displayBase64Image(String base64String) {
return Image.memory(
base64Decode(base64String),
fit: BoxFit.contain,
);
}
// Or with error handling
Widget safeDisplayImage(String base64String) {
try {
return Image.memory(
base64Decode(base64String),
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Center(child: Text('Failed to load image'));
},
);
} catch (e) {
return Center(child: Text('Invalid image data'));
}
}
Error Handling
HTTP Status Codes
| Status | Meaning | Action |
|---|---|---|
| 200 | Success | Process response |
| 400 | Bad Request | Check file format/size |
| 500 | Server Error | Retry or check logs |
Common Errors
"No file uploaded"
{"error": "No file uploaded"}
Cause: File not attached to request
Fix: Ensure file field is included in multipart request
"Could not load image"
{"error": "Could not load image"}
Cause: Corrupted or unsupported image format
Fix: Use JPEG/PNG, verify file integrity
"YOLO model not loaded"
{"error": "YOLO model not loaded"}
Cause: Backend not initialized properly
Fix: Restart backend, check models/best.pt exists
Timeout
Cause: Image processing takes >120 seconds
Fix: Reduce image size, check server performance
Code Examples
Complete Service Class
import 'package:http/http.dart' as http;
import 'dart:convert';
class LungCancerAnalysisService {
final String baseUrl = 'http://localhost:5001';
final int timeout = Duration(seconds: 120).inSeconds;
// Health check
Future<bool> healthCheck() async {
try {
final response = await http
.get(Uri.parse('$baseUrl/health'))
.timeout(Duration(seconds: 10));
return response.statusCode == 200;
} catch (e) {
print('Health check failed: $e');
return false;
}
}
// Validate CT scan
Future<ValidateCTResponse> validateCT(List<int> imageBytes) async {
try {
var request = http.MultipartRequest('POST',
Uri.parse('$baseUrl/validate-ct'));
request.files.add(
http.MultipartFile.fromBytes('file', imageBytes, filename: 'scan.jpg'),
);
request.headers['Accept'] = 'application/json';
final streamedResponse = await request.send()
.timeout(Duration(seconds: this.timeout));
final response = await http.Response.fromStream(streamedResponse);
if (response.statusCode == 200) {
final json = jsonDecode(response.body);
return ValidateCTResponse.fromJson(json);
} else {
throw Exception('Validation failed: ${response.body}');
}
} catch (e) {
throw Exception('CT validation error: $e');
}
}
// Full analysis with YOLO + Classification
Future<AnalysisResponse> analyze(List<int> imageBytes) async {
try {
var request = http.MultipartRequest('POST',
Uri.parse('$baseUrl/analyze'));
request.files.add(
http.MultipartFile.fromBytes('file', imageBytes, filename: 'scan.jpg'),
);
request.headers['Accept'] = 'application/json';
final streamedResponse = await request.send()
.timeout(Duration(seconds: this.timeout));
final response = await http.Response.fromStream(streamedResponse);
if (response.statusCode == 200) {
final json = jsonDecode(response.body);
return AnalysisResponse.fromJson(json);
} else {
throw Exception('Analysis failed: ${response.body}');
}
} catch (e) {
throw Exception('Analysis error: $e');
}
}
}
// Response models
class ValidateCTResponse {
final bool isCTScan;
final double colorScore;
final String message;
ValidateCTResponse({
required this.isCTScan,
required this.colorScore,
required this.message,
});
factory ValidateCTResponse.fromJson(Map<String, dynamic> json) {
return ValidateCTResponse(
isCTScan: json['is_ct_scan'] ?? false,
colorScore: (json['color_score'] ?? 0).toDouble(),
message: json['message'] ?? '',
);
}
}
class AnalysisResponse {
final bool success;
final int tumorsDetected;
final List<TumorDetection> detections;
final String detectionImage; // base64
final String heatmapImage; // base64
final String? message;
AnalysisResponse({
required this.success,
required this.tumorsDetected,
required this.detections,
required this.detectionImage,
required this.heatmapImage,
this.message,
});
factory AnalysisResponse.fromJson(Map<String, dynamic> json) {
return AnalysisResponse(
success: json['success'] ?? false,
tumorsDetected: json['tumors_detected'] ?? 0,
detections: (json['detections'] as List)
.map((d) => TumorDetection.fromJson(d))
.toList(),
detectionImage: json['detection_image'] ?? '',
heatmapImage: json['heatmap_image'] ?? '',
message: json['message'],
);
}
}
class TumorDetection {
final int tumorId;
final List<int> bbox;
final List<int> bboxWithPadding;
final String prediction;
final double confidence;
final Map<String, double> allConfidences;
final String cropImage; // base64
final String heatmapImage; // base64
TumorDetection({
required this.tumorId,
required this.bbox,
required this.bboxWithPadding,
required this.prediction,
required this.confidence,
required this.allConfidences,
required this.cropImage,
required this.heatmapImage,
});
factory TumorDetection.fromJson(Map<String, dynamic> json) {
return TumorDetection(
tumorId: json['tumor_id'] ?? 0,
bbox: List<int>.from(json['bbox'] ?? []),
bboxWithPadding: List<int>.from(json['bbox_with_padding'] ?? []),
prediction: json['prediction'] ?? 'Unknown',
confidence: (json['confidence'] ?? 0).toDouble(),
allConfidences: Map<String, double>.from(
(json['all_confidences'] as Map).map(
(k, v) => MapEntry(k, (v as num).toDouble()),
),
),
cropImage: json['crop_image'] ?? '',
heatmapImage: json['heatmap_image'] ?? '',
);
}
}
Usage in Widget
class CancerAnalysisScreen extends StatefulWidget {
@override
_CancerAnalysisScreenState createState() => _CancerAnalysisScreenState();
}
class _CancerAnalysisScreenState extends State<CancerAnalysisScreen> {
final service = LungCancerAnalysisService();
bool isLoading = false;
AnalysisResponse? result;
String? errorMessage;
void analyzeImage(List<int> imageBytes) async {
setState(() {
isLoading = true;
errorMessage = null;
});
try {
// Step 1: Validate CT scan
final validation = await service.validateCT(imageBytes);
if (!validation.isCTScan) {
setState(() {
errorMessage = 'Not a CT scan (score: ${validation.colorScore})';
isLoading = false;
});
return;
}
// Step 2: Full analysis
final analysis = await service.analyze(imageBytes);
setState(() {
result = analysis;
isLoading = false;
});
} catch (e) {
setState(() {
errorMessage = 'Error: $e';
isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Lung Cancer Analysis')),
body: SingleChildScrollView(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
children: [
if (isLoading)
Center(
child: Column(
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Analyzing image...'),
],
),
),
if (errorMessage != null)
Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Text(
errorMessage!,
style: TextStyle(color: Colors.red.shade900),
),
),
if (result != null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
child: Padding(
padding: EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tumors Detected: ${result!.tumorsDetected}',
style: Theme.of(context).textTheme.titleLarge,
),
SizedBox(height: 16),
// Detection image
Text('Detection Image:'),
Image.memory(
base64Decode(result!.detectionImage),
fit: BoxFit.contain,
),
SizedBox(height: 16),
// Heatmap image
Text('Heatmap (Highest Confidence):'),
Image.memory(
base64Decode(result!.heatmapImage),
fit: BoxFit.contain,
),
],
),
),
),
SizedBox(height: 16),
// Individual tumor details
...result!.detections.map((tumor) {
return Card(
child: Padding(
padding: EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tumor #${tumor.tumorId}',
style: Theme.of(context).textTheme.titleMedium,
),
Text('Classification: ${tumor.prediction}'),
Text('Confidence: ${tumor.confidence.toStringAsFixed(2)}%'),
SizedBox(height: 12),
Text('All Confidences:'),
...tumor.allConfidences.entries.map((e) {
return Text(
' ${e.key}: ${e.value.toStringAsFixed(2)}%',
);
}).toList(),
SizedBox(height: 12),
Text('Bounding Box: ${tumor.bbox}'),
],
),
),
);
}).toList(),
],
),
],
),
),
),
);
}
}
UI Integration
Recommended UI Flow
βββββββββββββββββββββββ
β Home Screen β
β - Upload Image β
β - Take Photo β
β - Gallery β
ββββββββββββ¬βββββββββββ
β
βΌ
βββββββββββββββββββββββ
β Validation Screen β
β - Show Color Score β
β - CT Scan Check β
β - Retry/Continue β
ββββββββββββ¬βββββββββββ
β
βΌ
βββββββββββββββββββββββ
β Loading Screen β
β - Progress Indicatorβ
β - "Analyzing..." β
β - Timeout Handling β
ββββββββββββ¬βββββββββββ
β
βΌ
ββββββββββββββββββββββββ
β Results Screen β
β ββββββββββββββββββββ β
β β Detection Image β β
β ββββββββββββββββββββ€ β
β β Heatmap Image β β
β ββββββββββββββββββββ€ β
β β Tumor Details β β
β β - Confidence β β
β β - Classification β β
β β - Per-class % β β
β ββββββββββββββββββββ β
ββββββββββββββββββββββββ
Confidence Visualization
Widget buildConfidenceBar(String className, double confidence) {
final color = confidence > 0.4
? Colors.red
: confidence > 0.2
? Colors.orange
: Colors.green;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(className),
LinearProgressIndicator(
value: confidence / 100,
minHeight: 8,
backgroundColor: Colors.grey.shade300,
valueColor: AlwaysStoppedAnimation(color),
),
Text('${confidence.toStringAsFixed(2)}%'),
],
);
}
Image Display with Zoom
import 'package:photo_view/photo_view.dart';
Widget zoomableImage(String base64String) {
return PhotoView(
imageProvider: MemoryImage(base64Decode(base64String)),
minScale: PhotoViewComputedScale.contained * 0.8,
maxScale: PhotoViewComputedScale.covered * 2,
);
}
Best Practices
1. Handle Timeouts Gracefully
Future<AnalysisResponse> analyzeWithTimeout(List<int> imageBytes) async {
try {
return await service.analyze(imageBytes).timeout(
Duration(seconds: 120),
onTimeout: () => throw TimeoutException('Analysis took too long'),
);
} on TimeoutException {
// Show user-friendly message
// Suggest reducing image size
}
}
2. Validate Before Upload
bool validateImage(List<int> imageBytes) {
// Check file size (limit to 10MB)
const maxSizeBytes = 10 * 1024 * 1024; // 10 MB
if (imageBytes.length > maxSizeBytes) {
return false;
}
// Check JPEG/PNG signature
if (imageBytes[0] == 0xFF && imageBytes[1] == 0xD8) {
return true; // JPEG
}
if (imageBytes[0] == 0x89 && imageBytes[1] == 0x50) {
return true; // PNG
}
return false;
}
3. Cache Results
class AnalysisCache {
static final Map<String, AnalysisResponse> _cache = {};
static String hashImage(List<int> imageBytes) {
return sha256.convert(imageBytes).toString();
}
static void store(List<int> imageBytes, AnalysisResponse response) {
_cache[hashImage(imageBytes)] = response;
}
static AnalysisResponse? retrieve(List<int> imageBytes) {
return _cache[hashImage(imageBytes)];
}
}
4. Permission Handling
import 'package:permission_handler/permission_handler.dart';
Future<bool> requestCameraPermission() async {
final status = await Permission.camera.request();
return status.isGranted;
}
Future<bool> requestGalleryPermission() async {
final status = await Permission.photos.request();
return status.isGranted;
}
5. Network State Awareness
import 'package:connectivity_plus/connectivity_plus.dart';
bool isNetworkAvailable() {
final connectivity = Connectivity();
return connectivity.checkConnectivity().then((result) {
return result != ConnectivityResult.none;
});
}
// Show offline message if needed
Troubleshooting
Issue: "Connection refused"
Cause: Backend not running
Solution:
cd /path/to/backend
source .venv/bin/activate
python main.py
Issue: "Image too large"
Cause: File size > 10MB
Solution: Compress before upload
import 'package:flutter_image_compress/flutter_image_compress.dart';
List<int> compressImage(List<int> imageBytes) {
return XFile.fromData(imageBytes)
.compress(quality: 85)
.then((file) => file.readAsBytes());
}
Issue: "CORS Error"
Cause: Frontend URLs not whitelisted
Solution: Backend already has CORS enabled via Flask-CORS
Issue: "Timeout after 120s"
Cause: Large image or slow server
Solution:
- Reduce image resolution
- Check server CPU/memory
- Increase timeout threshold
Issue: "Invalid base64 image"
Cause: Corrupted response data
Solution:
- Log full response:
print(response.body) - Check server error logs
- Retry with different image
Issue: Model not loaded
Error: "YOLO model not loaded"
Cause: models/best.pt missing
Solution: Verify model files exist in backend
Environment-Specific Configuration
Development
const String API_BASE = 'http://localhost:5001';
const bool DEBUG = true;
const int TIMEOUT = 120;
Staging
const String API_BASE = 'https://staging-api.railway.app';
const bool DEBUG = true;
const int TIMEOUT = 120;
Production
const String API_BASE = 'https://your-production-api.railway.app';
const bool DEBUG = false;
const int TIMEOUT = 120;
Switch at Runtime
String getBaseUrl() {
if (kDebugMode) {
return 'http://localhost:5001';
}
return 'https://your-production-api.railway.app';
}
API Testing
cURL Examples
Validation:
curl -X POST http://localhost:5001/validate-ct \
-F "file=@path/to/scan.jpg"
Analysis:
curl -X POST http://localhost:5001/analyze \
-F "file=@path/to/scan.jpg"
Health Check:
curl http://localhost:5001/health
Postman Setup
- Create POST request to
http://localhost:5001/analyze - Go to "Body" tab β Select "form-data"
- Add key "file" (type: File)
- Select image file
- Send & view response
Dependencies for Flutter
Add to pubspec.yaml:
dependencies:
flutter:
sdk: flutter
http: ^1.1.0
image_picker: ^1.0.0
photo_view: ^0.14.0
flutter_image_compress: ^2.1.0
permission_handler: ^11.4.0
connectivity_plus: ^5.0.0
crypto: ^3.0.0
Performance Metrics
| Operation | Time | Notes |
|---|---|---|
| Image Upload | 1-5s | Depends on image size & network |
| YOLO Detection | 5-15s | GPU faster if available |
| DenseNet Classification | 2-5s | Per tumor detected |
| Grad-CAM Heatmap | 2-3s | Per tumor |
| Total | 10-30s | End-to-end (single tumor) |
Support & Contact
For issues or questions:
- Check Troubleshooting section
- Review API logs:
python main.pyconsole output - Test with cURL first: ensure backend works
- Check Flutter app logs:
flutter logs - Verify network connectivity & firewall rules
API Version: 1.0.0
Last Updated: March 8, 2026
Compatibility: Flutter 3.0+ , Dart 3.0+