基于Linux嵌入式开发板的轨道检测机器人(大创项目)

2024年2月重写本文。

前言

该机器人项目于2020年(大一)立项,于2021年验收通过。
总体项目相对于正常的国家级大创来说难度适中,但工程量较大。

本项目最初是一个企业提出的产学研合作开发的项目,是”电缆巡检机器人”,也是由我们小组负责开发。刚组队不就就遇到国家级大学生创新创业项目(后简称”大创”)申报,我们导师变将题目改为”轨道检查机器人”并参与大创立项评比,最终定级为国家级大创项目。

经过简要讨论和老师(仪器仪表专业毕业)指导后,将机器人的目标任务划分为如下几个部分:

  1. 机器人总体比例按照1:2缩放设计,放大后可在轻轨上运行。
  2. 机器人可拆卸为电控仓和底座,轻轨和铁轨分别设计两种不同的底座,换个底座即可适应不同的轨道巡检要求
  3. 机器人至少包含距离传感器、温湿度传感器、摄像头以及几个备用的IO接口以方便后期拓展传感器类型和数量
  4. 机器人包含图像算法,支持检测轨道移位、轻轨轨道裂缝等。
  5. 机器人支持图像传输,并使用Lora通信进行图像传输。
  6. 包含一套上位机以方便控制机器人。
    总预算为2万元。

项目总体被划分为如下

  1. 机械设计(大部分由本人完成)
  2. Linux嵌入式开发(本人完成)
  3. STM32电控部分开发(队友完成)
  4. Windows上位机开发(本人完成)

项目概要

最终结果

项目总结与评价

本项目算是我和我大学的三年竞赛的指导老师第一次合作的项目,也是我和我几个队友的第一次合作。作为第一次磨合,总会有一些需要改进的地方,因此不对团队的具体人物进行评价。下面主要是对项目本身进行评价:

1. 项目初步调研时未咨询行业内老师或专家的建议

作为一个”试图解决轨道交通巡检”痛点的机器人,但立项时从未听取交通运输方面的老师、教授或专家的意见,导致我们试图解决的”痛点”都是我们自认为的”痛点”。

在后来,我们用这个项目参加“全国大学生交通运输科技大赛”。该竞赛的校级比赛在我们学校的交通运输学院进行,在校级答辩时,交通运输学院的专家指出“你们所研究的铁路指形板、焊缝检查,都已经是成熟的不能再成熟的技术了,目前我国推进建设的高铁均是无缝轨道,不存在焊缝检查问题;至于你们研究的单轨式混凝土轨道梁裂缝检查也不是什么重要的痛点,轻轨一公里一站,最快速度不过五六十,一公里踩一脚刹车,对轨道的磨损、对轨道的要求没你们想象的这么高。并且高铁晚上是不通车的,晚上都要做例行巡检,单轨式列车也是如此。”

很明显,前期调研没有做好,这个设计并没能迎合真正的需求。虽然在教科赛里面还是拿了国二…

2. 项目推进过程中发生项目进度4个月停滞问题

严谨来讲,该问题属于组内问题。
在2020年3月底项目任务安排时,组内负责机械设计的同学承诺4月之前学完Solidworks,5月之前完成初版机械结构设计。但是4月检查进度时,该队友Solidworks并未学完,甚至直到5月才学完该软件。随后要求其加速绘制初版设计,但是由于缺少机械常识,外加进度拖沓,导致暑假7月时(即本文初次编写时)仍未给出合理的设计。随后我花费几天时间随手绘制了一个我的机械设计的思路发给该同学进行参考(即最终设计的思路,但是大小和参数不一致),并说”如果你实在想不出合理的结构,那你可以直接参考我的结构,最好开学之前把设计定稿,并开始寻找机械加工厂家进行制作”。然而到9月开学之前其仍在坚持自己的设计,并未能给出合理的设计方案。我和队友多次催促,然而他在说”我每天早上八九点就开始画图,画到晚上11点”。后来据其舍友说,他早上就打开Solidworks,然后一直画到晚上,但是画的不是机器人的结构设计,是在研究和修改网上开源的各种模型枪、飞机的模型。期间我多次劝说”要不直接使用我的方案吧”,并遭到回绝并说自己”在这个项目中不能啥都不干”。

直到20年10月,我强制要求其直接使用我的方案,并已经给出具体的模型及其参数,遭到回绝,坚持要求添加自己设计的部分。随后我同意其设计电控仓底板部分,并对我设计的底架部分导出平面加工图。直到11月,所有部分出图完毕,开始寻找加工,项目恢复推进。

在本文初次编写时(2020年7月),我负责的Linux开发和图像部分就基本完成,负责STM32单片机开发的队友也完成既定任务,而本项目直到2020年11月之前整个项目都处于停滞状态。

3. 机械设计缺乏科学指导,整体设计过于保守

虽然理论上我不负责机械设计,但是最终的机械设计也大多都是由我完成。在我整个机械设计过程中基本上都是凭经验和感觉进行设计。虽然设计时在很大程度上都考虑到了如何进行加工,但是整体来说力学设计都过于保守。

以上若干条问题均有本人的责任。当然,该项目较为轻松的按时结题,且从竞赛角度而言该项目是成功的,也如上文所说我们以及其他人又使用该项目完成若干比赛和学业设计。作为一个大一的本科生而言,出现这些问题才应当是”正常的”。而在后续项目中尽可能规避该问题也是”应该的”。

以下为原帖。


Qt5 中文乱码问题

在Qt的安装路径找到bin目录,搜索include文件夹,找到qglobal.h头文件,在其中加入以下代码:

1
2
3
#if _MSC_VER >= 1600
#pragma execution_character_set("utf-8")
#endif

本文创建于 2020-06-05
更新于 2020-07-31
完成于…

关联项目:
http://ports.h13.tech/OpenCV在Arm-Linux上的安装
http://ports.h13.tech/OpenCV提取铁轨轨道特征-一
http://ports.h13.tech/OpenCV提取最大联通面积-铁轨轨道特征-二

大学生创新创业训练计划

其实最开始我们老师只是想让我们做一个电缆检测机器人(企业外包)去考验一下我们小组的能力的。
但是刚好碰上了大创,于是我们老师提议让我们小组做一个轨道交通检修机器人。因为我们地处重庆,这个需求比较合理。

系统大体架构

Linux开发板做硬件和传感器控制以及图像识别,实时图传,自主循环,以及异常报告。
服务器做流量中转,端口映射,以及多台机器的状态汇总。
上位机做图传接收,以及手动控制界面。

其中

硬件部分:

  • 核心主控:
    核心主控采用了RK3288 Ubuntu开发板。

  • USB:
    摄像头采用USB摄像头和Linux开发板链接,虽然USB比MIPI更费CPU 但是MIPI不适合较长数据线通信。

  • GPIO:

  • SDIO:
    其实就只是挂载了一个SD卡…

  • I2C:
    加速度传感器

  • USART:

软件部分:

  • 网络部分:
    网络部分主要负责上下位机间通信,机器人手动控制,实时图传。
    1.内网穿透:
    2.实时图传:
    3.上下位机通信:

  • 硬件控制:
    硬件控制主要分为 个线程,进程间依靠 共享内存 通信。
    1.
    2.
    3.

  • 图像识别:
    图像识别采用了OpenCV(废话)的C++ api进行处理,分别检测轨道曲率,是否有裂缝,是否有障碍物等异常。

  • 上位机:

然后Linux通过json数据格式汇报各个传感器的数值
GPIO-PWM驱动电机驱动板

我有幸负责包括Linux开发板的全部电子硬件设计和硬件驱动部分。当然 作为一种锻炼 我还是尽量的用自己的技术去实现其他部分。

Linux开发板环境配置

OpenCV

上来先搞最难的 因为即使你其他简单的配置好了 难的没有 也GG
有点麻烦,参见:
http://www.h13studio.com/OpenCV%E5%9C%A8Arm-Linux%E4%B8%8A%E7%9A%84%E5%AE%89%E8%A3%85/
每次换板子 每次都会遇到新问题 →_→ 所以我把OpenCV独立出来并持续更新。
当然了 某大佬并不看好我不用树莓派而采用其他Linux开发板的方式(虽然我也不想) 但是对于参加比赛来说 在技术允许的条件下 我还是能不用Arduino和树莓派就不用这俩 太LOW。
但是不能直接sudo apt-get install opencv的话 的确会浪费一些时间去折腾不是特别有必要的东西。但是这也不失为一种锻炼。

Zerotier内网穿透

一句话安装(其实是两句):

1
curl -s https://install.zerotier.com | sudo bash

Motion

先试试:

1
sudo apt-get install motion

如果没有这个库的话 需要用源码安装
https://motion-project.github.io

1
2
make -j4
sudo make install

Motion配置

首先去/etc/motion/motion.conf
里面正确选择cameraID

1
2
3
4
5
6
7
###########################################################
# Capture device options
############################################################

# Videodevice to be used for capturing (default /dev/video0)
# for FreeBSD default is /dev/bktr0
videodevice /dev/video0

然后配置分辨率

1
2
3
4
5
# Image width (pixels). Valid range: Camera dependent, default: 352
width 640

# Image height (pixels). Valid range: Camera dependent, default: 288
height 480

帧率
摄像头捕获帧率:

1
2
3
4
5
6
7
8
# Maximum number of frames to be captured per second.
# Valid range: 2-100. Default: 100 (almost no limit).
framerate 100

# Minimum time in seconds between capturing picture frames from the camera.
# Default: 0 = disabled - the capture rate is given by the camera framerate.
# This option is used when you want to capture images at a rate lower than 2 per second.
minimum_frame_time 30

视频流帧率:

1
2
# Maximum framerate for stream streams (default: 1)
stream_maxrate 30

图传端口

1
2
# The mini-http server listens to this port for requests (default: 0 = disabled)
stream_port 8081

关闭localhost限制

1
2
# Restrict stream connections to localhost only (default: on)
stream_localhost off
1
2
# Restrict control connections to localhost only (default: on)
webcontrol_localhost off

允许ipv6访问

1
2
# Enable or disable IPV6 for http control and stream (default: off )
ipv6_enabled on

等一切调试完毕, 开启后台运行模式

1
2
# Start in daemon (background) mode and release terminal (default: off)
daemon on

更多可配置项详见
https://www.jianshu.com/p/0e66d9b6b87d

2021-02-28更新:
motion自带的导出视频的视频速度好像有点问题, 几分钟的视频录出来就几秒
所以自己写了个Shell脚本去手动保存视频

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#!/bin/bash

while [ `date "+%Y"`a = '2016a' ];do
sleep 1
done

date_time=`date "+%Y-%m-%d-%H-%M-%S"`
times=0

rm -rf /mnt/sd/Video/${date_time}
mkdir /mnt/sd/Video/${date_time}

while true;do
http_code=`curl -I -m 10 -o /dev/null -s -w %{http_code} 127.0.0.1:8081`
echo `date`
if [ ${http_code}a = '200a' ];then
ffmpeg -i http://127.0.0.1:8081 -t 60 -c copy /mnt/sd/Video/${date_time}/${times}.avi
((times+=1))
else
echo "http code: "${http_code}
echo "Waiting for motion..."
sleep 1
fi
done

因为我这个系统开机之后直接获取date会获取到当前是2016年, 所以我就等待他获取到正确时间之后再继续程序

DDNS IPV6动态解析

NPS内网穿透

其实我用nps主要是因为他稳定 性价比不如上面两种方案好。
NPS我打算让他做内网穿透的最后一道防线 如果在IPV4的某种NAT模式下 Zerotier IPV6均失败的话 这种方式就会派上用场。
但是对于图传来说 阿里云学生机带宽有点小了 1兆很容易导致Motion崩溃。

去官方Release页面下载对应架构最新版本Sever&Cilent
https://github.com/ehang-io/nps/releases
PS: 其实如果你板子是32位的Arm板子的话(即使是Cortex-A17) 直接用Cortex-A7版本即可,通用的。
如果是64位板子 就用Arm64版本。
下载后 解压到无权限的临时目录 最好是HOME/temp之类的
然后:

1
sudo ./npc install

然后给一个配置文件路径(这里我用的是/etc/npc/conf/npc.conf)
配置文件随便写写就OK 之所以用NPS的原因就是有Web面板 配置方便。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[common]
server_addr=你的IP:8024
conn_type=tcp
vkey=Robot0
auto_reconnection=true
max_conn=9999
flow_limit=9999
rate_limit=9999
basic_username=Robot1
basic_password=Robot1
crypt=true
compress=true
#pprof_addr=0.0.0.0:9999
disconnect_timeout=60

[health_check_test1]
health_check_timeout=1
health_check_max_failed=3
health_check_interval=1
health_http_url=/
health_check_type=http
health_check_target=ip地址:8083,ip地址:8082

然后加入开机自启 即可。

1
2
# StartUp NPC
sudo npc -config=/etc/npc/conf/npc.conf

i2c-tools

1
2
3
4
git clone https://github.com/ev3dev/i2c-tools.git
cd i2c-tools/
make
sudo make install

传感器部分

六个月前刚开始时,我还是Linux小白,当时决定做这个项目的时候就想着”能用就行”,所以一开始我也没有打算写驱动,直接用 文件操作函数调用i2c-adapter 实现一遍STM32上的函数即可。
所以,对于STM32/STM8上有库文件的模块,直接移植和重写一遍i2c通信部分即可。

Linux的基本思想为 “万物皆文件” ,下文中我所用到的各种接口如GPIO i2c USART 甚至 CPU 你都可以在磁盘里面找到对应文件。

eg: CPU文件

1
ls /sys/bus/cpu/devices/

这里,我使用了vsergeev大佬的C语言外围设备库c-periphery
https://github.com/vsergeev/c-periphery

其实我最开始想用 Tao_Liu 大佬的一个i2c库去做移植,因为整个库很精简,但是他只有i2c库,所以为了统一,我还是用c-periphery了。

USART

1

SPI

1

GPIO

Linux莫名其妙不给gpio的库,
刚刚提到的c-periphery直接死在了#include <linux/gpio.h>
不过这个项目需要用到GPIO的地方不多 顶多响个蜂鸣器啥的,大多数都是普通协议接口和PWM

PWM部分

电机驱动板

I2C部分

i2c-adapter

1
ls /dev/i2c*

然后输出的即为你i2c-adapter的IO文件

I2C地址检测

这里使用了i2c-tools进行硬件地址检测 安装方式看前文

1
i2cdetect -y -r 0 # 0可以换成I2C的ID号

然后很简单的得到了如下对应表

1
2
3
4
Device       Address
OLED12864 0x3C
MPU6050
ADS1115 0x48 //ADDR->GND

当然 众所周知 通常I2C模块地址是可以自行改变的,这里只是我自己的地址。

i2c移植基本思路

MPU6050

ADS1115

USART部分

GPS

SPI部分

HDMI部分

显示屏

买个40P的LCD,买个HDMI转接板,买根HDMI公对公线,插上直接用。
回来我试试修改分辨率重新编译一下固件。

USB部分

为了一个比赛手写USB驱动 疯了么?以下硬件只要买Linux免驱版本,插上就能用 →_→

4G LTE网卡

USB摄像头

先拔下USB摄像头

1
ls /dev/video*

再插上USB摄像头

1
ls /dev/video*

多出来的摄像头即为你USB摄像头的文件

1

主要控制逻辑及代码

部署开机自启

至此 电子硬件部分全部完毕。

机械结构

OpenCV算法

这一部分其实不归我管,但是我喜欢探索未知。所以我也按照我的思路去实现了一遍。
http://www.h13studio.com/OpenCV提取铁轨轨道特征-一
http://www.h13studio.com/OpenCV提取最大联通面积-铁轨轨道特征-二

其中 需要注意一些Linux和Windows少量不同的地方
如:
微秒级计时:

1
2
3
4
Windows:
windows.h ->
Linux:

可能会用到的pause指令:

1
2
3
4
Windows
stdlib -> system('pause');
Linux
unistd -> pause();

以及在编译时 shell命令行结尾应添加pkg-config --cflags --libs opencv
eg:

1
g++ -o input.cpp output `pkg-config --cflags --libs opencv`

上位机部分

这一部分其实也不归我管 →_→

不过上位机用C#写,极其简单。
为了方便把这个 轨道检测机器人 改成 电缆检测机器人 等各种机器人,我一开始就打算用 JSON + TCP 做上下位机的通信,然后上位机直接遍历解析JSON然后把JSON的信息画到TreeView等UI里。
这样,以后只需要在把 轨道检测机器人 改成 电缆检测机器人 的时候,只需要在重写传感器底层驱动的时候顺便改一下JSON的格式就行,而上位机则什么都不需要改。

图传接收部分

我们选择的是 Motion 做http图传,那么只需要一个浏览器就可以解析图传。
其实也不是所有的浏览器都能很好的解析Motion传回的图像,就比如 @IE浏览器 就能把Motion搞崩溃。
所以我直接一步到胃用了Chrome。

接下来就是在上位机中内置Chrome:
先创建好项目,然后


然后你的UI框架是Winform的话 就选择Winform版本,WPF则选择WPF版本。

然后工程调试部分选择x86


然后工具箱里就多了一个Chrome浏览器控件(如果没有的话 重启一下Visual Studio)

然后就完事了,图传也正常。

下位机JSON遍历解析部分

其实C#做这个的唯一难度就是需要会一点面向对象编程。
未来我会写一个Json配置器,方便给C++写的Mirai插件做UI配置部分。
不过目前已经做好了一个遍历Json绘制TreeView的JsonViewer,直接用来处理TCP Cilent收到的信息即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

using System.Windows.Forms;

namespace Json_Viewer
{
class TraversingJSON
{
private List<TreeNode> treenode = new List<TreeNode>();
private TreeNode CurrentTreeNode;

public void ReadJson(String input, TreeView _TreeView)
{
JObject _jObject = JObject.Parse(input);
ProcessObjects(_jObject,_TreeView);
}

public void ProcessObjects(JObject _jObject, TreeView _TreeView)
{
TreeNode Object = new TreeNode("Root:");
treenode.Add(Object);
_TreeView.Nodes.Add(Object);

CurrentTreeNode = _TreeView.Nodes[0];

foreach (JProperty item in _jObject.Children())
{
Traversing(item);
}
}

private void ProcessObjects(JProperty _JProperty)
{
CurrentTreeNode.Nodes.Add(_JProperty.Name + ":");

CurrentTreeNode = CurrentTreeNode.Nodes[CurrentTreeNode.Nodes.Count - 1];

foreach (JProperty item in JObject.Parse(_JProperty.Value.ToString()).Children())
{
Traversing(item);
}

CurrentTreeNode = CurrentTreeNode.Parent;
}

private void ProcessObjects(JToken _JToken)
{
CurrentTreeNode.Nodes.Add(_JToken.ToString());
}

private void ProcessArray(JProperty _JProperty)
{

CurrentTreeNode.Nodes.Add(_JProperty.Name + ":");

//加深一层
CurrentTreeNode = CurrentTreeNode.Nodes[CurrentTreeNode.Nodes.Count - 1];

if (((JArray)_JProperty.Value).Count > 0)
{
int index = 0;
foreach(JToken _JToken in _JProperty.Value.Children())
{
index++;
switch (_JToken.Type)
{
case JTokenType.Object:
ProcessObjects(_JToken);
break;

case JTokenType.Array:
ProcessArray(_JToken);
break;

default:
CurrentTreeNode.Nodes.Add(_JToken.ToString());
break;
}
Console.WriteLine("Type of Array is: " + _JToken.Type);
Console.WriteLine("Value of " + index + " is: " + _JToken.ToString());
}
}

CurrentTreeNode = CurrentTreeNode.Parent;

Console.WriteLine("Number of Array is: " + ((JArray)_JProperty.Value).Count);
}

private void ProcessArray(JToken _JToken)
{
CurrentTreeNode.Nodes.Add(_JToken.ToString());
}

private void ProcessFloat(JProperty _JProperty)
{
CurrentTreeNode.Nodes.Add(_JProperty.Name + ": " + _JProperty.Value);
}

private void ProcessString(JProperty _JProperty)
{
CurrentTreeNode.Nodes.Add(_JProperty.Name + ": " + _JProperty.Value);
}

private void ProcessInteger(JProperty _JProperty)
{
CurrentTreeNode.Nodes.Add(_JProperty.Name + ": " + _JProperty.Value);
}

private void Traversing(JProperty item)
{
switch (item.Value.Type)
{
case JTokenType.None:
Console.WriteLine(item.Name + " is not set to a token type.");
break;

case JTokenType.Object:
Console.WriteLine(item.Name + " is a Object and its value is: " + item.Value);
ProcessObjects(item);
break;

case JTokenType.Array:
Console.WriteLine(item.Name + " is a Array and its value is: " + item.Value);
ProcessArray(item);
break;

case JTokenType.Constructor:

break;

case JTokenType.Property:

break;

case JTokenType.Comment:

break;

case JTokenType.Integer:
ProcessInteger(item);
break;

case JTokenType.Float:
Console.WriteLine(item.Name + " is a Float and its value is: " + item.Value);
ProcessFloat(item);
break;

case JTokenType.String:
ProcessString(item);
Console.WriteLine(item.Name + " is a String and its value is: " + item.Value);
break;

case JTokenType.Boolean:
Console.WriteLine(item.Name + " is a Boolean and its value is: " + item.Value);
break;

case JTokenType.Null:
Console.WriteLine(item.Name + " is NULL.");
break;

case JTokenType.Undefined:
Console.WriteLine(item.Name + " is Undefined.");
break;

case JTokenType.Date:
Console.WriteLine(item.Name + " is a Date and its value is " + item.Value);
break;

case JTokenType.Raw:
Console.WriteLine(item.Name + " is a Raw and its value is " + item.Value);
break;

case JTokenType.Guid:
Console.WriteLine(item.Name + " is a Guid and its value is " + item.Value);
break;

case JTokenType.Uri:
Console.WriteLine(item.Name + " is a Uri and its value is " + item.Value);
break;

case JTokenType.TimeSpan:
Console.WriteLine(item.Name + " is a TimeSpan and its value is " + item.Value);
break;
}
}
}
}

把上面的代码保存为TraversingJSON.cs添加到项目中
然后使用的时候直接把 Treeview 控件传入接口即可。
eg:

1
2
3
4
5
using Json_Viewer;
//然后
TraversingJSON TJ = new TraversingJSON();
TJ.ReadJson("{\"key1\":\"123\",\"key2\":{\"key3\":{\"key4\":[122,211],\"key5\":3.14}},\"key6\":2.71828,\"key7\":[[111,222],[222,111]],\"key8\":[{\"key9\":\"999999\"},{\"中文测试\":\"1010\"}]}", treeView1);
treeView1.ExpandAll();

TCP Cilent

再往下无非就是UI设计,往窗口拖几个按钮就完事了。

本项目中用到的开源项目

Linux: (这个好像没必要加吧..)
https://github.com/topics/linux

OpenCV:
https://opencv.org/

i2c-tools:
https://github.com/ev3dev/i2c-tools

Motion:
https://motion-project.github.io

NPS:
https://github.com/ehang-io/nps

C语言外围设备库 c-periphery :
https://github.com/vsergeev/c-periphery

曾考虑过的项目:

Zerotier:
https://www.zerotier.com/

C语言的一个i2c基础操作库:
https://www.cnblogs.com/helloworldtoyou/p/5899342.html?utm_source=itdadao&utm_medium=referral

以及提供了不少外援技术支持的大佬(们):
https://uint128.com/
林老师NB!

下回打死我我也不随便接这种项目了,工程量太大了

emmmm….想了一下,好像也不是特别大?反正我搞了好久