human_security 0.0.2
human_security: ^0.0.2 copied to clipboard
On-device face liveness verification with ML Kit active challenges, TFLite anti-spoofing, and optional face identity matching.
example/lib/main.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:human_security/human_security.dart';
import 'package:image_picker/image_picker.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const FaceLivenessExampleApp());
}
class FaceLivenessExampleApp extends StatelessWidget {
const FaceLivenessExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Face Liveness Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF1B5E20)),
useMaterial3: true,
),
home: const HomePage(),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final _urlController = TextEditingController();
final _picker = ImagePicker();
bool _enableFaceMatch = false;
File? _referenceFile;
String? _referenceUrl;
@override
void dispose() {
_urlController.dispose();
super.dispose();
}
ReferenceImageSource? get _referenceSource {
if (!_enableFaceMatch) return null;
if (_referenceFile != null) {
return ReferenceImageSource.file(_referenceFile!);
}
final url = _referenceUrl ?? _urlController.text.trim();
if (url.isNotEmpty) {
return ReferenceImageSource.url(url);
}
return null;
}
Future<void> _pickReferenceImage() async {
final picked = await _picker.pickImage(
source: ImageSource.gallery,
imageQuality: 90,
maxWidth: 1920,
);
if (picked == null) return;
setState(() {
_referenceFile = File(picked.path);
_referenceUrl = null;
_urlController.clear();
});
}
void _openLivenessCheck({required bool passive}) {
final reference = _referenceSource;
if (_enableFaceMatch && (reference == null || reference.isEmpty)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Pick a reference image or enter an image URL first.'),
),
);
return;
}
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => LivenessCheckPage(
enablePassiveLiveness: passive,
referenceImage: reference,
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Face Liveness Demo'),
centerTitle: true,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.verified_user, size: 72),
const SizedBox(height: 24),
const Text(
'Test live face verification',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
const Text(
'Runs active ML Kit challenges and passive TFLite anti-spoofing. '
'Optionally compare the live capture against a reference photo.',
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Compare with reference image'),
subtitle: const Text(
'Match live face against uploaded or URL image',
),
value: _enableFaceMatch,
onChanged: (value) => setState(() => _enableFaceMatch = value),
),
if (_enableFaceMatch) ...[
OutlinedButton.icon(
onPressed: _pickReferenceImage,
icon: const Icon(Icons.photo_library_outlined),
label: Text(
_referenceFile == null
? 'Pick reference from gallery'
: 'Reference: ${_referenceFile!.path.split('/').last}',
),
),
const SizedBox(height: 12),
TextField(
controller: _urlController,
decoration: const InputDecoration(
labelText: 'Or paste image URL',
border: OutlineInputBorder(),
hintText: 'https://example.com/photo.jpg',
),
onChanged: (_) {
if (_urlController.text.trim().isNotEmpty) {
setState(() {
_referenceFile = null;
_referenceUrl = _urlController.text.trim();
});
}
},
),
if (_referenceFile != null ||
(_referenceUrl?.isNotEmpty ?? false))
Padding(
padding: const EdgeInsets.only(top: 12),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: _referenceFile != null
? Image.file(
_referenceFile!,
height: 120,
fit: BoxFit.cover,
)
: Image.network(
_referenceUrl!,
height: 120,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
const SizedBox(
height: 120,
child: Center(
child: Text('Could not preview URL'),
),
),
),
),
),
const SizedBox(height: 24),
],
FilledButton.icon(
onPressed: () => _openLivenessCheck(passive: true),
icon: const Icon(Icons.face_retouching_natural),
label: const Text('Start Full Liveness Check'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => _openLivenessCheck(passive: false),
icon: const Icon(Icons.motion_photos_on),
label: const Text('Active Only (No TFLite)'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
],
),
),
);
}
}
class LivenessCheckPage extends StatefulWidget {
const LivenessCheckPage({
super.key,
required this.enablePassiveLiveness,
this.referenceImage,
});
final bool enablePassiveLiveness;
final ReferenceImageSource? referenceImage;
@override
State<LivenessCheckPage> createState() => _LivenessCheckPageState();
}
class _LivenessCheckPageState extends State<LivenessCheckPage> {
void _onSuccess(File image, FaceMatchReport? report) {
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(
builder: (_) => ResultPage.success(image: image, report: report),
),
);
}
void _onFailure(String reason) {
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(
builder: (_) => ResultPage.failure(
reason: reason,
enablePassiveLiveness: widget.enablePassiveLiveness,
referenceImage: widget.referenceImage,
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: FaceLivenessDetector(
enablePassiveLiveness: widget.enablePassiveLiveness,
referenceImage: widget.referenceImage,
onSuccess: _onSuccess,
onFailure: _onFailure,
),
),
);
}
}
class ResultPage extends StatelessWidget {
const ResultPage._({
required this.isSuccess,
this.image,
this.report,
this.reason,
this.enablePassiveLiveness = true,
this.referenceImage,
});
factory ResultPage.success({required File image, FaceMatchReport? report}) {
return ResultPage._(isSuccess: true, image: image, report: report);
}
factory ResultPage.failure({
required String reason,
bool enablePassiveLiveness = true,
ReferenceImageSource? referenceImage,
}) {
return ResultPage._(
isSuccess: false,
reason: reason,
enablePassiveLiveness: enablePassiveLiveness,
referenceImage: referenceImage,
);
}
final bool isSuccess;
final File? image;
final FaceMatchReport? report;
final String? reason;
final bool enablePassiveLiveness;
final ReferenceImageSource? referenceImage;
@override
Widget build(BuildContext context) {
final matchEnabled = report?.referenceImage != null;
return Scaffold(
appBar: AppBar(title: Text(isSuccess ? 'Verified' : 'Failed')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Icon(
isSuccess ? Icons.check_circle : Icons.cancel,
size: 80,
color: isSuccess ? Colors.green : Colors.red,
),
const SizedBox(height: 16),
Text(
isSuccess
? 'Live face verified successfully.'
: reason ?? 'Verification failed.',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 18),
),
if (report != null) ...[
const SizedBox(height: 24),
_ScoreCard(
label: 'Liveness',
value: report!.isLive ? 'Passed' : 'Failed',
progress: report!.livenessResult?.combinedScore ?? 0,
),
if (matchEnabled) ...[
const SizedBox(height: 12),
_ScoreCard(
label: 'Face match',
value: '${(report!.similarityScore * 100).round()}%',
progress: report!.similarityScore,
subtitle: report!.isSamePerson
? 'Same person'
: 'Different person',
),
],
],
if (isSuccess && image != null) ...[
const SizedBox(height: 24),
if (matchEnabled && report?.referenceImage != null)
Row(
children: [
Expanded(
child: _ImagePreview(
label: 'Reference',
image: report!.referenceImage!,
),
),
const SizedBox(width: 12),
Expanded(
child: _ImagePreview(
label: 'Live capture',
image: image!,
),
),
],
)
else
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
image!,
height: 240,
width: double.infinity,
fit: BoxFit.cover,
),
),
],
const SizedBox(height: 32),
FilledButton(
onPressed: () {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute<void>(builder: (_) => const HomePage()),
(_) => false,
);
},
child: const Text('Back to Home'),
),
const SizedBox(height: 8),
if (!isSuccess)
TextButton(
onPressed: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(
builder: (_) => LivenessCheckPage(
enablePassiveLiveness: enablePassiveLiveness,
referenceImage: referenceImage,
),
),
);
},
child: const Text('Try Again'),
),
],
),
),
);
}
}
class _ScoreCard extends StatelessWidget {
const _ScoreCard({
required this.label,
required this.value,
required this.progress,
this.subtitle,
});
final String label;
final String value;
final double progress;
final String? subtitle;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: const TextStyle(fontWeight: FontWeight.w600),
),
Text(value),
],
),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(subtitle!, style: Theme.of(context).textTheme.bodySmall),
],
const SizedBox(height: 8),
LinearProgressIndicator(value: progress.clamp(0, 1)),
],
),
),
);
}
}
class _ImagePreview extends StatelessWidget {
const _ImagePreview({required this.label, required this.image});
final String label;
final File image;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
image,
height: 140,
width: double.infinity,
fit: BoxFit.cover,
),
),
],
);
}
}