기능
- 사용자가 카메라를 통해 의약품의 바코드를 스캔하면, 의약품 정보가 보인다.
- 만약 스캔한 사진의 형체를 알아볼 수 없는 등의 기타 오류가 있다면 재촬영이 필요하다고 안내한다.
- 사진 인식 유무는 서버에 배포된 ML 모델을 통해 확인할 수 있다.

바코드 스캔 구현
dependencies:
#Camera
camera: ^0.9.4+1
# Barcode scanner
google_mlkit_barcode_scanning: 0.5.0
flutter_beep: ^1.0.0
flutter_vibrate: ^1.3.0
# REST API
http: ^0.13.5
pubspec.yaml에 사용할 모듈 추가
shootQr.dart
@override
void initState() {
super.initState();
_initCamera();
_initRecog();
}
Future<void> _initCamera() async {
final cameras = await availableCameras(); // 사용할 수 있는 카메라 목록을 OS로부터 받아온다
_cameraController =
CameraController(cameras[cameraIndex], ResolutionPreset.veryHigh);
_initCameraControllerFuture = _cameraController!.initialize().then((value) {
setState(() {});
});
}
void _initRecog() async {
bool canVibrate = await Vibrate.canVibrate; // 진동 관련
setState(() {
_canVibrate = canVibrate;
_canVibrate
? debugPrint("This device can vibrate")
: debugPrint("This device cannot vibrate");
});
}
initState()를 통해 카메라와 진동 관련 변수를 초기화시킨다.
StatefulWidget : 변경 가능한 상태를 가진 Widget이다. 상태에 따른 UI의 변화가 필요할 때 StatefulWidget을 사용한다. (자세한 내용 참고)
body: Column(
children: [
// 카메라가 앱 화면에 꽉 차게 만들기 위해서 사용
Flexible(
flex: 3,
fit: FlexFit.tight,
child: FutureBuilder<void>(
future: _initCameraControllerFuture, // 비동기 함수
builder: (BuildContext context, AsyncSnapshot snapshot) {
// ConnectionState.done은 비동기 데이터 수신이 완료되었다는 의미
if (snapshot.connectionState == ConnectionState.done) {
// 3초마다 snapshot을 찍어서, 결과 도출 or 서버로 보내는 함수
Future.delayed(const Duration(seconds: 3), () {
cameraSnapshot();
});
return SizedBox(
width: size.width,
height: size.width,
child: ClipRect(
child: FittedBox(
fit: BoxFit.fitWidth,
child: SizedBox(
width: size.width,
child: AspectRatio(
// 카메라 비율 설정
aspectRatio:
1 / _cameraController!.value.aspectRatio,
child: CameraPreview(_cameraController!)),
),
),
),
);
}
// 데이터 수신이 완료되지 못했을 때(데이터를 받아오지 못했을 때)
else {
return Center(child: CircularProgressIndicator());
}
},
),
),
],
),
시각장애인들을 위한 기능이기 때문에 사용자가 직접 촬영을 하는 대신 3초의 시간을 두고 자동으로 촬영되도록 기능을 구현한다.
비동기 함수 _initCameraControllerFuture를 호출할 때, FutureBuilder를 통해 데이터가 불러와지기 전에는 로딩 화면을 띄우고 데이터가 불러와지면 3초마다 cameraSnapshot() 메서드(카메라로 스냅샷을 찍어 결과를 도출하는 함수)를 호출한다.
Scaffold : 디자인적 뼈대를 구성하는 위젯으로 시각적인 레이아웃 구조를 실행한다.
FutureBuilder : Flutter의 경우 비동기 통신을 사용하고 있는데 이는 동기식 통신과 다르게 서버에서 데이터를 모두 받아오기 전 화면을 그릴 수 있다는 장점을 가진다. FutureBuilder를 사용하여 데이터를 불러오기 전 데이터 없이 화면을 먼저 그릴 수 있다. 만약 FutureBuilder가 없다면 데이터가 다 불러와지길 기다린 후 화면을 그리거나, 화면을 그린 다음 데이터가 변하면 setState()를 통해 UI를 바꾸어 주어야 할 것이다.
// 카메라 촬영 함수
Future<void> cameraSnapshot() async {
try {
await _cameraController!.takePicture().then((value) async {
// InputImage.fromFilePath(): 로컬 이미지 파일 Uri에서 InputImage를 생성
await scanBar(InputImage.fromFilePath(value.path));
});
// 화면 상태 변경
setState(() {});
} catch (e) {
print("$e");
}
}
}
바코드 스캔의 목적은 해당 제품이 의약품인지 확인하고 의약품 정보를 제공하는 것이다. 촬영을 한 사진이 유효한 의약품 바코드인지 확인 절차를 거쳐야 한다.
CameraController의 takePicture 메서드를 사용하여 사진을 촬영한다.
촬영한 이미지의 경로(value.path)를 ML Kit(google_mlkit_barcode_scanning)의 InputImage 클래스 메서드인 fromFilePath()에 인자로 넘겨주어 InputImage를 생성한다. 생성한 이미지에 유효한 의약품 바코드가 존재하는지 확인하기 위해 scanBar() 메서드를 호출한다.
Future <void> 함수 : 해당 함수를 콜 하는 부분에서 비동기처리를 확실하게 완료하고 싶을 때 사용한다.
+) void 함수는 비동기 처리를 하되 완료 시점은 상관 없는 경우에 사용한다. (자세한 내용 참고)
// InputImage에 유효한 의약품 바코드가 존재하는지 확인하는 함수
Future scanBar(InputImage inputImage) async {
// BarcodeScanner 인스턴스 생성
final List<BarcodeFormat> formats = [BarcodeFormat.all];
final barcodeScanner = BarcodeScanner(formats: formats);
final List<Barcode> barcodes =
await barcodeScanner.processImage(inputImage);
print("--------------------------barcodes: $barcodes-----------------");
print("------------------barcodes length: ${barcodes.length}-------------");
// 바코드 인식을 실패했을 때
if (barcodes.isEmpty) {
// 이미지를 서버로 보내는 코드
await hitAPI(inputImage);
} else {
for (Barcode barcode in barcodes) {
final BarcodeType type = barcode.type;
final String? displayValue = barcode.displayValue;
final bool isMedi;
if (displayValue == null) {
// 유효한 바코드가 아닐 경우
tts.speak("올바른 바코드를 스캔해 주세요.");
} else {
switch (type) {
case BarcodeType.product: // 제품 코드에 대한 바코드
isMedi = checkMedicine(displayValue);
if (isMedi) {
qrCallback(displayValue);
} else {
tts.speak("의약품의 바코드를 스캔해 주세요.");
}
break;
default:
tts.speak("의약품의 바코드를 스캔해 주세요.");
break;
}
}
}
}
}
BarcodeScanner 인스턴스를 생성하고 processImage() 메서드를 통해 이미지에서 바코드를 찾는다.
만약 바코드 인식 실패했다면 재촬영 안내를 위해 해당 이미지를 서버로 보낸다.
바코드 인식에 성공했다면 바코드의 displayValue와 type에 따라서 유효한 바코드인지, 제품 코드가 담긴 바코드인지 확인한다. 제품 코드가 담긴 바코드가 맞다면 checkMedicine() 메서드를 호출하여 해당 제품이 의약품인지 확인한다.

// 바코드의 제품 코드가 의약품 코드인지 확인하는 함수
bool checkMedicine(String code) {
final int index;
final String cutCode;
final String businessCode;
bool isMedi = false;
var codeSize = code.length;
var startCodeList = ['0499', '6199', '6399'];
var endCodeList = ['1000', '6299', '6999'];
if (code.contains('880') && codeSize >= 13) {
index = code.indexOf('880');
cutCode = code.substring(index, 13);
businessCode = cutCode.substring(3, 7);
for (int i = 0; i < 3; i++) {
if (businessCode.compareTo(startCodeList[i]) == 1 &&
businessCode.compareTo(endCodeList[i]) == -1) {
isMedi = true;
break;
}
}
}
return isMedi;
}
국내에서 유통되는 모든 의약품에는 고유의 번호가 있다. 개개의 의약품을 식별하기 위해 지난 2008년부터 의약품 표준코드(Korea Drug Code, 이하 KD코드) 표기가 의무화됐기 때문이다.
총 13자리로 구성된 KD코드에는 각 숫자별로 의미가 부여돼 있다. 순서대로 맨 앞 3자리는 국가식별코드이고, 이어 4자리는 제약사, 그 다음 5자리는 품목, 나머지 1자리는 검증번호코드이다.
바코드의 제품 코드가 의약품 코드인지 확인하기 위해서 함수를 통해 검증한다.


void qrCallback(String code) {
FlutterBeep.beep(); // 비프음
if (_canVibrate) Vibrate.feedback(FeedbackType.heavy); // 진동
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => SearchResultPage(code),
),
);
}
바코드에 담긴 정보가 의약품 코드라면 displayValue에 저장되어있는 의약품 코드(넘겨 받은 매개변수 code)를 결과 페이지로 넘긴다.
// 이미지를 서버로 넘겨 결과를 받아오는 함수
Future<void> hitAPI(InputImage inputImage) async {
try {
final request = http.MultipartRequest(
'POST',
Uri.parse('http://34.64.96.217:8000/guide/'), // REST API 에 액세스하는 서버 주소
);
// request에 이미지 파일 추가
request.files.add(
await http.MultipartFile.fromPath(
'file',
inputImage.filePath!,
contentType: MediaType('image', 'png'),
),
);
final response = await http.Response.fromStream(await request.send());
if (response.statusCode == 200) {
// json 응답값을 decode (utf8.decode: 한글이 깨지는 문제를 해결)
final responseBody = json.decode(utf8.decode(response.bodyBytes));
String responseBodyCut = responseBody.toString().substring(9);
tts.speak(responseBodyCut.toString());
} else {
print(
"Fail connect: ${response.statusCode}");
}
} catch (e) {
print("error: $e");
}
}
사진 상에서 바코드 인식에 실패했을 경우, 서버에 바코드 촬영 이미지를 보내고 서버에 배포된 ML 모델이 해당 이미지를 인식하여 상황에 맞게 '손을 왼쪽으로 천천히 움직여주세요' 등의 메시지를 반환한다.
http 패키지를 통해 이미지를 추가한 request를 서버로 보내고 응답을 받아온다. 이후 json 응답을 decode하여 얻은 jsonBody를 바탕으로 데이터를 핸들링한다.
일반적으로 get, post 함수로 통신하면 결과로 Response 타입의 데이터를 가져오지만 MultipartRequest는 결과로 StreamedResponse 타입의 데이터를 가져온다. Response 클래스 내의 fromStream() 함수를 이용해 StreamedResponse를 Response로 변경하면 get, post의 결과처럼 처리가 가능하다.


[Reference]
StatefulWidget Lifecycle https://jaceshim.github.io/2019/01/28/flutter-study-stateful-widget-lifecycle/
FutureBuilder https://eory96study.tistory.com/21 & http://www.incodom.kr/Flutter/FutureBuilder
AspectRatio https://velog.io/@sharveka_11/AspectRatio
Http https://marrrang.tistory.com/22 & https://leeseongho.tistory.com/133