当前位置: 首页 > news >正文

计算机视觉案例分析之银行卡号识别

简介

计算机视觉第一课opencv(三)保姆级教学

计算机视觉第一课opencv(一)保姆级教学

计算机视觉第一课opencv(二)保姆级教学

计算机视觉第一课opencv(四)保姆级教学

        关于计算机视觉的基础内容我们之前已经说完了,今天我们就来用一个小案例来回顾并丰富一下我们的知识。

一、整体流程

  1. 预处理模板图像,提取数字模板
  2. 预处理信用卡图像,定位数字区域
  3. 对每个数字区域进行处理,提取单个数字
  4. 使用模板匹配识别每个数字
  5. 输出识别结果

card.png

template.png

这是数字模板,就是把银行卡号与这里面每一个数字对比实现模糊匹配

二、代码分析

1. 导入工具包和参数设置
# 导入工具包
import numpy as np                  # 用于数值计算和数组操作
import argparse                     # 用于解析命令行参数
import cv2                          # OpenCV库,用于图像处理
import myutils                      # 自定义工具函数,包含图像 resize、轮廓排序等功能

argparse用于解析命令行参数,允许用户通过命令行指定输入图像和模板图像:

# 设置命令行参数
# 创建 ArgumentParser 对象,用于定义和解析命令行参数
ap = argparse.ArgumentParser()
# 添加输入图像参数
ap.add_argument("-i", "--image", required=True,help="path to input image")  # 输入信用卡图片的路径
# 添加模板图像参数
ap.add_argument("-t", "--template", required=True,help="path to template OCR-A image")  # 数字模板图片的路径
# 解析参数并转换为字典形式
args = vars(ap.parse_args())

定义信用卡类型映射表,根据卡号第一位判断:

FIRST_NUMBER = {"3": "American Express","4": "Visa","5": "MasterCard","6": "Discover Card"}

辅助函数cv_show用于显示图像:

def cv_show(name, img):cv2.imshow(name, img)cv2.waitKey(0)  # 等待按键,0表示无限等待

myutils.py

import cv2def sort_contours(cnts, method='left-to-right'):reverse = Falsei = 0if method == 'right-to-left' or method == 'bottom-to-top':reverse = Trueif method == 'top-to-bottom' or method == 'bottom-to-top':i = 1boundingBoxes = [cv2.boundingRect(c) for c in cnts](cnts, boundingBoxes) = zip(*sorted(zip(cnts, boundingBoxes),key=lambda b: b[1][i], reverse=reverse))# zip(*...) 使用星号操作符解包排序后的元组列表,并将其重新组合成两个列表:一个包含所有轮廓,另一个包含所有边界框。return cnts, boundingBoxesdef resize(image, width=None, height=None, inter=cv2.INTER_AREA):dim = None(h, w) = image.shape[:2]if width is None and height is None:return imageif width is None:r = height / float(h)dim = (int(w * r), height)else:r = width / float(w)dim = (width, int(h * r))resized = cv2.resize(image, dim, interpolation=inter)     #默认是cv2.INTER_AREA,即面积插值,适用于缩放图像return resized

这里定义了两个函数方法:

1. sort_contours函数:轮廓排序

这个函数的作用是按照指定的方式对图像中的轮廓进行排序,方便后续的图像处理和分析。

参数说明

  • cnts:需要排序的轮廓列表(通常来自cv2.findContours的返回值)
  • method:排序方法,可选值包括:
    • 'left-to-right'(默认):从左到右排序
    • 'right-to-left':从右到左排序
    • 'top-to-bottom':从上到下排序
    • 'bottom-to-top':从下到上排序

工作原理

  1. 首先根据排序方法设置reverse(是否反转排序结果)和i(排序依据的坐标索引):

    • 对于水平方向排序(左右),使用 x 坐标(索引 0)
    • 对于垂直方向排序(上下),使用 y 坐标(索引 1)
    • 反向排序(如 right-to-left)时设置reverse=True
  2. boundingBoxes = [cv2.boundingRect(c) for c in cnts]

    • 为每个轮廓计算边界框(外接矩形)
    • cv2.boundingRect(c)返回一个元组(x, y, w, h),其中 (x,y) 是矩形左上角坐标,w 和 h 是宽和高
  3. 排序部分:

    • zip(cnts, boundingBoxes)将轮廓与其对应的边界框组合成元组
    • sorted(..., key=lambda b: b[1][i], reverse=reverse)根据边界框的指定坐标(x 或 y)进行排序
    • zip(*...)解包排序后的结果,重新组合成轮廓列表和边界框列表
  4. 返回排序后的轮廓和对应的边界框

2. resize函数:图像缩放

这个函数用于按比例调整图像的尺寸,可以指定宽度或高度,保持原图的宽高比。

参数说明

  • image:输入图像
  • width:目标宽度(可选)
  • height:目标高度(可选)
  • inter:插值方法,默认为cv2.INTER_AREA(面积插值)

工作原理

  1. 首先获取原图的高度和宽度:(h, w) = image.shape[:2]

  2. 处理特殊情况:

    • 如果宽度和高度都未指定,直接返回原图
    • 如果只指定高度,则计算高度的缩放比例r = height / float(h),再计算对应的宽度
    • 如果只指定宽度,则计算宽度的缩放比例r = width / float(w),再计算对应的高度
  3. 执行缩放:

    • cv2.resize(image, dim, interpolation=inter)使用计算出的目标尺寸dim进行缩放
    • 默认使用cv2.INTER_AREA插值方法,这种方法在缩小图像时效果较好,能保持图像质量
  4. 返回缩放后的图像

使用场景

  • sort_contours常用于需要按顺序处理轮廓的场景,如识别数字、字符时按阅读顺序排列
  • resize是预处理的常用步骤,用于统一图像尺寸,方便后续处理或显示
2. 模板图像处理(创建数字模板库)

这部分的目的是从模板图像中提取 0-9 的数字特征,建立模板库:

# 读取模板图像
img = cv2.imread(args["template"])
cv_show('img', img)# 转换为灰度图
ref = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
cv_show('ref', ref)# 转换为二值图像(黑底白字)
ref = cv2.threshold(ref, 10, 255, cv2.THRESH_BINARY_INV)[1]
cv_show('ref', ref)

查找模板中的数字轮廓:

_, refCnts, hierarchy = cv2.findContours(ref, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# 绘制轮廓查看效果
cv2.drawContours(img, refCnts, -1, color=(0, 0, 255), thickness=3)
cv_show('img', img)

对轮廓进行排序(从左到右):

refCnts = myutils.sort_contours(refCnts, method="left-to-right")[0]

提取每个数字的 ROI(感兴趣区域)并保存到字典中:

digits = {}
for (i, c) in enumerate(refCnts):(x, y, w, h) = cv2.boundingRect(c)  # 获取外接矩形roi = ref[y:y + h, x:x + w]roi = cv2.resize(roi, dsize=(57, 88))  # 统一大小digits[i] = roi  # 存储模板
3. 信用卡图像处理

读取并预处理信用卡图像:

image = cv2.imread(args["image"])
cv_show('image', image)
image = myutils.resize(image, width=300)  # 调整大小便于处理
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)  # 转为灰度图
cv_show('gray', gray)

顶帽操作(突出亮区域,抑制暗背景):

rectKernel = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 3))  # 定义矩形卷积核
tophat = cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, rectKernel)
cv_show('tophat', tophat)

闭操作(连接数字,形成完整区域):

closeX = cv2.morphologyEx(tophat, cv2.MORPH_CLOSE, rectKernel)
cv_show('gradX', closeX)# 二值化处理
thresh = cv2.threshold(closeX, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1]
cv_show('thresh', thresh)# 再次闭操作,确保数字区域完整
thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, sqKernel)
cv_show('thresh1', thresh)
4. 定位数字区域

查找图像中的轮廓,定位数字区域:

_, threshCnts, h = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cnts = threshCnts# 绘制所有轮廓查看效果
cur_img = image.copy()
cv2.drawContours(cur_img, cnts, -1, (0, 0, 255), thickness=3)
cv_show('img', cur_img)

筛选出符合信用卡数字区域特征的轮廓:

locs = []
for (i, c) in enumerate(cnts):(x, y, w, h) = cv2.boundingRect(c)ar = w / float(h)  # 宽高比# 信用卡数字区域通常宽高比在2.5-4.0之间if ar > 2.5 and ar < 4.0:if (w > 40 and w < 55) and (h > 10 and h < 20):locs.append((x, y, w, h))# 按x坐标排序(从左到右)
locs = sorted(locs, key=lambda x: x[0])
5. 识别每个数字
output = []
for (i, (gx, gy, gw, gh)) in enumerate(locs):groupOutput = []# 提取数字区域,适当扩大边界group = gray[gy - 5:gy + gh + 5, gx - 5:gx + gw + 5]cv_show('group', group)# 二值化处理group = cv2.threshold(group, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1]cv_show('group', group)# 查找每个数字的轮廓group_, digitCnts, hierarchy = cv2.findContours(group.copy(), cv2.RETR_EXTERNAL,cv2.CHAIN_APPROX_SIMPLE)digitCnts = myutils.sort_contours(digitCnts, method="left-to-right")[0]

使用模板匹配识别每个数字:

for c in digitCnts:(x, y, w, h) = cv2.boundingRect(c)roi = group[y:y + h, x:x + w]roi = cv2.resize(roi, (57, 88))  # 调整为与模板相同大小# 与每个模板进行匹配scores = []for (digit, digitROI) in digits.items():result = cv2.matchTemplate(roi, digitROI, cv2.TM_CCOEFF)(_, score, _, _) = cv2.minMaxLoc(result)scores.append(score)# 取匹配得分最高的数字groupOutput.append(str(np.argmax(scores)))
6. 显示和输出结果
# 在图像上绘制识别结果
cv2.rectangle(image, (gx - 5, gy - 5), (gx + gw + 5, gy + gh + 5), (0, 0, 255), 1)
cv2.putText(image, "".join(groupOutput), (gx, gy - 15), cv2.FONT_HERSHEY_SIMPLEX, 0.65, (0, 0, 255), 2)# 保存结果
output.extend(groupOutput)# 打印识别结果
print("Credit Card Type: {}".format(FIRST_NUMBER[output[0]]))
print("Credit Card #: {}".format("".join(output)))
cv2.imshow("Image", image)
cv2.waitKey(0)

三.总体代码

myutils.py在上面

# coding: utf-8
'''
任务书:要求给一家银行设计一套信用卡识别系统。
功能:传入一张信用卡图片,自动识别并输出信用卡中的数字
'''# 导入工具包
import numpy as np                  # 用于数值计算和数组操作
import argparse                     # 用于解析命令行参数
import cv2                          # OpenCV库,用于图像处理
import myutils                      # 自定义工具函数,包含图像 resize、轮廓排序等功能'''
命令行参数示例:
-i card1.png  # 指定输入的信用卡图片路径
-t template.png  # 指定包含数字模板的图片路径
'''# 设置命令行参数
# 创建 ArgumentParser 对象,用于定义和解析命令行参数
ap = argparse.ArgumentParser()
# 添加输入图像参数
ap.add_argument("-i", "--image", required=True,help="path to input image")  # 输入信用卡图片的路径
# 添加模板图像参数
ap.add_argument("-t", "--template", required=True,help="path to template OCR-A image")  # 数字模板图片的路径
# 解析参数并转换为字典形式
args = vars(ap.parse_args())# 指定信用卡类型映射表,根据卡号第一位判断
FIRST_NUMBER = {"3": "American Express",    # 美国运通卡"4": "Visa",                # 维萨卡"5": "MasterCard",          # 万事达卡"6": "Discover Card"}       # 发现卡def cv_show(name, img):  # 图像展示函数cv2.imshow(name, img)  # 显示图像,name为窗口名称,img为图像数据cv2.waitKey(0)         # 等待用户按键,0表示无限等待# 注意:原代码缺少cv2.destroyAllWindows(),实际使用时建议添加以释放窗口资源'''--------模板图像中数字的定位与提取--------'''
# 读取模板图像
img = cv2.imread(args["template"])
cv_show('Template Image', img)  # 显示原始模板图像# 将模板图像转换为灰度图(简化图像处理,减少计算量)
ref = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
cv_show('Grayscale Template', ref)  # 显示灰度模板图像# 对灰度图进行二值化处理(黑白图像),采用反相阈值(THRESH_BINARY_INV)
# 反相处理后变为黑底白字,更便于后续轮廓检测
ref = cv2.threshold(ref, 10, 255, cv2.THRESH_BINARY_INV)[1]
cv_show('Binary Inverted Template', ref)  # 显示二值化后的模板图像# 查找模板图像中的轮廓
# cv2.findContours()函数返回三个值:处理后的图像、轮廓列表、轮廓层次结构
# cv2.RETR_EXTERNAL:只检测外轮廓
# cv2.CHAIN_APPROX_SIMPLE:只保留轮廓的端点坐标,减少存储
_, refCnts, hierarchy = cv2.findContours(ref, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)# 在原始模板图像上绘制找到的轮廓(红色),便于可视化
cv2.drawContours(img, refCnts, -1, color=(0, 0, 255), thickness=3)
cv_show('Template with Contours', img)  # 显示带有轮廓的模板图像# 对轮廓进行排序(从左到右),确保数字顺序正确
refCnts = myutils.sort_contours(refCnts, method="left-to-right")[0]# 创建字典存储每个数字对应的模板图像
digits = {}# 遍历每一个轮廓,提取并处理数字模板
for (i, c) in enumerate(refCnts):# 计算轮廓的外接矩形(x,y为左上角坐标,w,h为宽高)(x, y, w, h) = cv2.boundingRect(c)# 提取数字区域(ROI:Region of Interest)roi = ref[y:y + h, x:x + w]# 将数字区域调整为统一大小(57x88像素),便于后续模板匹配roi = cv2.resize(roi, dsize=(57, 88))cv_show(f'Digit Template {i}', roi)  # 显示每个数字的模板digits[i] = roi  # 将数字模板存入字典,键为数字值,值为对应的图像'''--------信用卡图像处理与数字识别--------'''
# 读取输入的信用卡图像
image = cv2.imread(args["image"])
cv_show('Original Credit Card Image', image)  # 显示原始信用卡图像# 调整信用卡图像大小(宽度设为300像素,高度按比例缩放)
image = myutils.resize(image, width=300)
# 将信用卡图像转换为灰度图
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
cv_show('Grayscale Credit Card', gray)  # 显示灰度信用卡图像# 顶帽操作(Top Hat):突出图像中的亮区域,抑制暗区域
# 作用是消除背景干扰,突出信用卡上的数字(通常数字是亮色的)
# 创建矩形结构元素(卷积核),用于形态学操作
rectKernel = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 3))
# 创建另一个矩形结构元素,用于后续的闭操作
sqKernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))# 执行顶帽操作:原始图像 - 开运算结果(先腐蚀后膨胀)
tophat = cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, rectKernel)
cv_show('Top Hat Result', tophat)  # 显示顶帽操作结果# -------定位数字区域的边框--------
# 1、通过闭操作(先膨胀后腐蚀)将数字连接成一个整体区域
closeX = cv2.morphologyEx(tophat, cv2.MORPH_CLOSE, rectKernel)
cv_show('Close Operation Result', closeX)  # 显示闭操作结果# 对闭操作结果进行二值化处理
# 使用OTSU自动阈值法(适合双峰分布的图像),需将阈值参数设为0
thresh = cv2.threshold(closeX, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1]
cv_show('Binary Threshold Result', thresh)  # 显示二值化结果# 再次执行闭操作,进一步连接可能断开的数字区域
thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, sqKernel)
cv_show('Second Close Operation Result', thresh)  # 显示第二次闭操作结果# 查找二值化图像中的轮廓
_, threshCnts, h = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)# 在原始信用卡图像上绘制所有找到的轮廓(红色)
cur_img = image.copy()
cv2.drawContours(cur_img, threshCnts, -1, (0, 0, 255), thickness=3)
cv_show('Credit Card with All Contours', cur_img)  # 显示带有所有轮廓的信用卡图像# 筛选出可能包含数字的轮廓区域
locs = []  # 存储符合条件的轮廓位置信息# 遍历所有轮廓
for (i, c) in enumerate(threshCnts):# 计算轮廓的外接矩形(x, y, w, h) = cv2.boundingRect(c)# 计算宽高比(aspect ratio)ar = w / float(h)# 根据信用卡数字区域的特征筛选轮廓:# 1. 宽高比通常在2.5到4.0之间(数字区域是横向的矩形)# 2. 宽度在40-55像素之间,高度在10-20像素之间if ar > 2.5 and ar < 4.0:if (w > 40 and w < 55) and (h > 10 and h < 20):locs.append((x, y, w, h))  # 将符合条件的轮廓位置加入列表# 将符合条件的轮廓按x坐标排序(从左到右),确保数字顺序正确
locs = sorted(locs, key=lambda x: x[0])# 识别每个数字区域中的具体数字
output = []  # 存储最终识别的信用卡数字# 遍历每一个数字区域
for (i, (gx, gy, gw, gh)) in enumerate(locs):groupOutput = []  # 存储当前数字区域识别的数字# 提取数字区域(适当扩大边界5像素,确保包含完整数字)group = gray[gy - 5:gy + gh + 5, gx - 5:gx + gw + 5]cv_show(f'Digit Group {i}', group)  # 显示当前数字区域# 对数字区域进行二值化处理group = cv2.threshold(group, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1]cv_show(f'Binarized Digit Group {i}', group)  # 显示二值化后的数字区域# 查找当前数字区域内每个数字的轮廓group_, digitCnts, hierarchy = cv2.findContours(group.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)# 对数字轮廓进行排序(从左到右)digitCnts = myutils.sort_contours(digitCnts, method="left-to-right")[0]# 识别每个数字for c in digitCnts:# 计算单个数字的外接矩形(x, y, w, h) = cv2.boundingRect(c)# 提取单个数字区域roi = group[y:y + h, x:x + w]# 调整为与模板相同的大小(57x88像素)roi = cv2.resize(roi, (57, 88))cv_show('Single Digit ROI', roi)  # 显示单个数字区域'''-------使用模板匹配识别数字---------'''scores = []  # 存储与每个模板的匹配得分# 与每个数字模板进行匹配for (digit, digitROI) in digits.items():# 使用相关系数匹配方法(TM_CCOEFF)进行模板匹配result = cv2.matchTemplate(roi, digitROI, cv2.TM_CCOEFF)# 提取匹配结果中的最大值(最佳匹配得分)(_, score, _, _) = cv2.minMaxLoc(result)scores.append(score)# 找到得分最高的模板对应的数字,即为识别结果groupOutput.append(str(np.argmax(scores)))# 在信用卡图像上绘制识别结果# 绘制矩形框住数字区域cv2.rectangle(image, pt1=(gx - 5, gy - 5),  # 左上角坐标pt2=(gx + gw + 5, gy + gh + 5),  # 右下角坐标color=(0, 0, 255),  # 红色thickness=1)  # 线宽# 在矩形上方绘制识别的数字cv2.putText(image, "".join(groupOutput),  # 要显示的文本(gx, gy - 15),  # 文本位置(左下角)cv2.FONT_HERSHEY_SIMPLEX,  # 字体fontScale=0.65,  # 字体大小color=(0, 0, 255),  # 红色thickness=2)  # 字重# 将当前数字区域的识别结果添加到总结果中output.extend(groupOutput)# 输出最终识别结果
print("信用卡类型: {}".format(FIRST_NUMBER[output[0]]))  # 根据第一位数字判断卡类型
print("信用卡号码: {}".format("".join(output)))  # 拼接所有数字并显示# 显示带有识别结果的信用卡图像
cv2.imshow("Final Result", image)
cv2.waitKey(0)
cv2.destroyAllWindows()  # 释放所有窗口资源


文章转载自:

http://0XflavE1.nzfqw.cn
http://Tm92i6e6.nzfqw.cn
http://GLq6EJGM.nzfqw.cn
http://uuIy4dL3.nzfqw.cn
http://4CYx4qDJ.nzfqw.cn
http://npjLAIdm.nzfqw.cn
http://wdJrs56E.nzfqw.cn
http://XWbjKt8v.nzfqw.cn
http://0rE9PeVs.nzfqw.cn
http://WkHXkXCD.nzfqw.cn
http://jzQ0QbN2.nzfqw.cn
http://sJuBaC3d.nzfqw.cn
http://CVbBFNY5.nzfqw.cn
http://c3O7Ebrg.nzfqw.cn
http://I7m3f4Ie.nzfqw.cn
http://N7ZdLnzl.nzfqw.cn
http://tvtjv4Bi.nzfqw.cn
http://04B86Bae.nzfqw.cn
http://1kb4SXgT.nzfqw.cn
http://ELbGH9Gr.nzfqw.cn
http://PlXME6lc.nzfqw.cn
http://MHgnZWd1.nzfqw.cn
http://GxMzMlDH.nzfqw.cn
http://K4hh4Ff2.nzfqw.cn
http://Eh8gwOUl.nzfqw.cn
http://VN4COGkA.nzfqw.cn
http://cGVZC1k5.nzfqw.cn
http://lP9JmdX7.nzfqw.cn
http://nB7hsfae.nzfqw.cn
http://EnFnuma9.nzfqw.cn
http://www.dtcms.com/a/373748.html

相关文章:

  • 【motion】音乐节奏特征:bpm与舞蹈的适配性
  • Spark 核心原理:RDD, DataFrame, DataSet 的深度解析
  • 三轴云台之电子换向技术篇
  • gradient_accumulation_steps的含义
  • 经典视觉跟踪算法的MATLAB实现
  • 编译器构造:从零手写汇编与反汇编程序(一)
  • 【Ubuntu20.04 + VS code 1.103.2 最新版,中文输入法失效】
  • 【开题答辩全过程】以 基于Python的北城公务用车系统设计与实现_为例,包含答辩的问题和答案
  • Proximal SFT:用PPO强化学习机制优化SFT,让大模型训练更稳定
  • 2025年Q3 GEO优化供应商技术能力评估与行业应用指南
  • 25上半年软考网工备考心得
  • XPath:从入门到能用
  • Kotlin协程 -> Job.join() 完整流程图与核心源码分析
  • [优选算法专题二滑动窗口——串联所有单词的子串]
  • VR森林防火模拟进行零风险演练,成本降低​
  • 玩转Docker | 使用Docker部署Kener状态页监控工具
  • Oracle 官网账号登不了?考过的证书还能下载吗?
  • Oracle 数据库高级查询语句方法
  • WSD3075DN56高性能MOS管在汽车电动助力转向系统(EPS)中的应用
  • 1.1 汽车运行滚动阻力
  • LinuxC++项目开发日志——高并发内存池(3-thread cache框架开发)
  • Android 自定义 TagView
  • 下沉一线强赋能!晓商圈多维帮扶护航城市共建者
  • YOLO12 改进、魔改|通道自注意力卷积块CSA-ConvBlock,通过动态建模特征图通道间的依赖关系,优化通道权重分配,在强化有效特征、抑制冗余信息
  • 提升数据库性能的秘密武器:深入解析慢查询、连接池与Druid监控
  • 中间件的日志分析
  • 机器宠物外壳设计的详细流程
  • OpenCV C++ 二值图像分析:从连通组件到轮廓匹配
  • Java分页 Element—UI
  • Flow-GRPO: Training Flow Matching Models via Online RL