Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[rostwitter] Support extracting base64 images and tweet them from text. #375

Closed
wants to merge 21 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b6a7be9
[rostwitter] Enable to tweet with 280(hankaku) characters
iory Aug 16, 2022
cfa0ff7
[rostwitter] Fixed encoding for python2
iory Aug 16, 2022
3bc02a7
[rostwitter] Changed so that strings longer than 140 characters are d…
iory Aug 16, 2022
ea3fbe7
[rostwitter] Remove _check_and_split_word function
iory Aug 17, 2022
a707445
[rostwitter] Add cv_utils to encode/decode image and extract images f…
iory Aug 17, 2022
5519101
[rostwitter] Use SafeLoader for yaml.load
iory Aug 17, 2022
6c33442
[rostwitter] Support extracting base64 images and tweet them from text.
iory Aug 17, 2022
3efafa2
[rostwitter] Delete StringIO. StringIO eliminates newline \n
iory Aug 19, 2022
714631b
[rostwitter] Separate tweets by image.
iory Aug 20, 2022
206cb38
[rostwitter] import izip_longest for python2 compatibility
iory Aug 22, 2022
77d692d
[rostwitter] Fixed input is text only case
iory Aug 23, 2022
d4d1b53
[rostwitter] Add launch file
iory Aug 23, 2022
e9c6016
[rostwitter] Add README
iory Aug 23, 2022
3d23437
[rostwitter] Assume base64 images are not contiguous without spaces
iory Sep 1, 2022
0be3862
[rostwitter] Add document for base64 image
iory Sep 1, 2022
ac921e9
[rostwitter] Add status_code to check error
iory Sep 22, 2022
71d5173
[rostwitter] Avoid error when tweet post returning error code
iory Sep 22, 2022
348eddf
[rostwitter] Add error_code to check error (#6)
nakane11 Sep 22, 2022
8e0d3a7
[rostwitter] Fix _check_post_request (#7)
nakane11 Sep 24, 2022
2209d73
Merge branch 'master' into twitter-with-base64-images
knorth55 Oct 25, 2022
e70df8f
Merge branch 'master' into twitter-with-base64-images
knorth55 Feb 16, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion rostwitter/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ else()
)
endif()

install(DIRECTORY test resource
install(DIRECTORY test resource launch
DESTINATION ${CATKIN_PACKAGE_SHARE_DESTINATION}
USE_SOURCE_PERMISSIONS
)
Expand Down
116 changes: 116 additions & 0 deletions rostwitter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# rostwitter

This package is a ROS wrapper for Twitter. You can tweet via ROS.

# How to use

## Get access key for API.

Please get access to the Twitter API. Please refer to the following URL.

https://developer.twitter.com/en/docs/twitter-api/getting-started/getting-access-to-the-twitter-api

After that, save the yaml file in the following format.

```
CKEY: <Your Consumer API Key>
CSECRET: <Your Consumer SECRET API Key>
AKEY: <Your API Key>
ASECRET: <Your API Secret Key>
```

## Launch tweet node

```
roslaunch rostwitter tweet.launch account_info:=<PATH TO YOUR YAML FILE>
```

## Tweet text

You can tweet by simply publish on the `/tweet` topic.

```
rostopic pub /tweet std_msgs/String "Hello. Tweet via rostwitter (https://github.com/jsk-ros-pkg/jsk_3rdparty)"
```

![](./doc/tweet-string.jpg)

If the string to be tweeted exceeds 140 full-width characters or 280 half-width characters, it will be tweeted in the "thread" display.

```
rostopic pub /tweet std_msgs/String """The Zen of Python, by Tim Peters

Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!
"""
```

![](./doc/tweet-string-thread.jpg)

## Tweet text with image

You can also tweet along with your images.

If a base64 or image path is inserted in the text, it will jump to the next reply in that section.

### Image path

```
wget https://github.com/k-okada.png -O /tmp/k-okada.png
rostopic pub /tweet std_msgs/String "/tmp/k-okada.png"
```

![](./doc/tweet-image-path.jpg)

### Base64

You can even tweet the image by encoding in base64. The following example is in python.

Do not concatenate multiple base64 images without spaces.


```python
import rospy
import cv2
import std_msgs.msg
import numpy as np
import matplotlib.cm

from rostwitter.cv_util import extract_media_from_text
from rostwitter.cv_util import encode_image_cv2

rospy.init_node('rostwitter_sample')
pub = rospy.Publisher('/tweet', std_msgs.msg.String, queue_size=1)
rospy.sleep(3.0)

colormap = matplotlib.cm.get_cmap('hsv')

text = 'Tweet with images. (https://github.com/jsk-ros-pkg/jsk_3rdparty/pull/375)\n'
N = 12
for i in range(N):
text += str(i)
color = colormap(1.0 * i / N)[:3]
img = color * np.ones((10, 10, 3), dtype=np.uint8) * 255
img = np.array(img, dtype=np.uint8)
text += encode_image_cv2(img) + ' '
pub.publish(text)
```

[The result of the tweet.](https://twitter.com/pr2jsk/status/1561995909524705280)
Binary file added rostwitter/doc/tweet-image-path.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added rostwitter/doc/tweet-string-thread.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added rostwitter/doc/tweet-string.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions rostwitter/launch/tweet.launch
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<launch>

<arg name="account_info" />
<arg name="output" default="screen"/>

<param name="account_info" value="$(arg account_info)" />
<node name="tweet"
pkg="rostwitter" type="tweet.py"
output="$(arg output)" respawn="true" >
</node>

</launch>
80 changes: 80 additions & 0 deletions rostwitter/python/rostwitter/cv_util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import base64
import imghdr
import os.path
import re

import cv2
import numpy as np
import rospy


base64_and_filepath_image_pattern = re.compile(r'((?:/9j/)(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)? ?|/\S+\.(?:jpeg|jpg|png|gif))')


def encode_image_cv2(img, quality=90):
encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), quality]
result, encimg = cv2.imencode('.jpg', img, encode_param)
b64encoded = base64.b64encode(encimg).decode('ascii')
return b64encoded


def decode_image_cv2(b64encoded):
bin = b64encoded.split(",")[-1]
bin = base64.b64decode(bin)
bin = np.frombuffer(bin, np.uint8)
img = cv2.imdecode(bin, cv2.IMREAD_COLOR)
return img


def is_base64_image(b64encoded):
try:
decode_image_cv2(b64encoded)
except Exception as e:
rospy.logerr(str(e))
return False
return True


def get_image_from_text(text):
if base64_and_filepath_image_pattern.match(text) is None:
return None

if os.path.exists(text):
path = text
if imghdr.what(path) in ['jpeg', 'png', 'gif']:
with open(path, 'rb') as f:
return f.read()
else:
succ = is_base64_image(text)
if succ:
bin = text.split(",")[-1]
bin = base64.b64decode(bin)
bin = np.frombuffer(bin, np.uint8)
return bin


def extract_media_from_text(text):
texts = base64_and_filepath_image_pattern.split(text)
target_texts = list(filter(lambda x: x is not None and len(x.strip()) > 0, texts))

split_texts = ['']
imgs_list = []

texts = []
imgs = []
for text in target_texts:
img = get_image_from_text(text)
if img is None:
split_texts.append(text)
imgs_list.append(imgs)
imgs = []
else:
imgs.append(img)

if len(imgs) > 0:
imgs_list.append(imgs)
if len(split_texts) > 0:
if len(split_texts[0]) == 0 and len(imgs_list[0]) == 0:
split_texts = split_texts[1:]
imgs_list = imgs_list[1:]
return imgs_list, split_texts
100 changes: 80 additions & 20 deletions rostwitter/python/rostwitter/twitter.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
# originally from https://raw.githubusercontent.com/bear/python-twitter/v1.1/twitter.py # NOQA

import math
import json as simplejson
import requests
from requests_oauthlib import OAuth1
# https://stackoverflow.com/questions/11914472/stringio-in-python3
try:
from StringIO import StringIO ## for Python 2
from itertools import zip_longest
except ImportError:
from io import StringIO ## for Python 3
from itertools import izip_longest as zip_longest
from requests_oauthlib import OAuth1

import rospy

from rostwitter.util import count_tweet_text
from rostwitter.util import split_tweet_text
from rostwitter.cv_util import extract_media_from_text


class Twitter(object):
def __init__(
Expand Down Expand Up @@ -54,24 +58,80 @@ def _request_url(self, url, verb, data=None):
)
return 0 # if not a POST or GET request

def post_update(self, status):
if len(status) > 140:
rospy.logwarn('tweet is too longer > 140 characters')
status = status[:140]
url = 'https://api.twitter.com/1.1/statuses/update.json'
data = {'status': StringIO(status)}
json = self._request_url(url, 'POST', data=data)
data = simplejson.loads(json.content)
def _check_post_request(self, request):
valid = True
data = simplejson.loads(request.content)
if request.status_code != 200:
rospy.logwarn('post tweet failed. status_code: {}'
.format(request.status_code))
if 'errors' in data:
for error in data['errors']:
rospy.logwarn('Tweet error code: {}, message: {}'
.format(error['code'], error['message']))
valid = False
if valid:
return data

def _post_update_with_reply(self, texts, media_list=None,
in_reply_to_status_id=None):
split_media_list = []
media_list = media_list or []
for i in range(0, int(math.ceil(len(media_list) / 4.0))):
split_media_list.append(media_list[i * 4:(i + 1) * 4])
for text, media_list in zip_longest(texts, split_media_list):
text = text or ''
media_list = media_list or []
url = 'https://api.twitter.com/1.1/statuses/update.json'
data = {'status': text}
media_ids = self._upload_media(media_list)
if len(media_ids) > 0:
data['media_ids'] = media_ids
if in_reply_to_status_id is not None:
data['in_reply_to_status_id'] = in_reply_to_status_id
r = self._request_url(url, 'POST', data=data)
data = self._check_post_request(r)
if data is not None:
in_reply_to_status_id = data['id']
return data

def _upload_media(self, media_list):
url = 'https://upload.twitter.com/1.1/media/upload.json'
media_ids = []
for media in media_list:
data = {'media': media}
r = self._request_url(url, 'POST', data=data)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in order to post numpy array data, we need to upgrade requests python module to 2.19.0 and above.
psf/requests@8546a15

if r.status_code == 200:
rospy.loginfo('upload media success')
media_ids.append(str(r.json()['media_id']))
else:
rospy.logerr('upload media failed. status_code: {}'
.format(r.status_code))
media_ids = ','.join(media_ids)
return media_ids

def post_update(self, status, in_reply_to_status_id=None):
media_list, status_list = extract_media_from_text(status)
for text, mlist in zip_longest(status_list, media_list):
text = text or ''
texts = split_tweet_text(text)
data = self._post_update_with_reply(
texts,
media_list=mlist,
in_reply_to_status_id=in_reply_to_status_id)
if data is not None:
in_reply_to_status_id = data['id']
return data

def post_media(self, status, media):
# 116 = 140 - len("http://t.co/ssssssssss")
if len(status) > 116:
rospy.logwarn('tweet wit media is too longer > 116 characters')
status = status[:116]
def post_media(self, status, media, in_reply_to_status_id=None):
texts = split_tweet_text(status)
status = texts[0]
url = 'https://api.twitter.com/1.1/statuses/update_with_media.json'
data = {'status': StringIO(status)}
data = {'status': status}
data['media'] = open(str(media), 'rb').read()
json = self._request_url(url, 'POST', data=data)
data = simplejson.loads(json.content)
r = self._request_url(url, 'POST', data=data)
data = self._check_post_request(r)
if len(texts) > 1:
data = self._post_update_with_reply(
texts[1:],
in_reply_to_status_id=data['id'])
return data
42 changes: 41 additions & 1 deletion rostwitter/python/rostwitter/util.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import os
import sys
import unicodedata
import yaml

import rospy
Expand All @@ -16,9 +18,47 @@ def load_oauth_settings(yaml_path):
rospy.logerr("EOF")
return None, None, None, None
with open(yaml_path, 'r') as f:
key = yaml.load(f)
key = yaml.load(f, Loader=yaml.SafeLoader)
ckey = key['CKEY']
csecret = key['CSECRET']
akey = key['AKEY']
asecret = key['ASECRET']
return ckey, csecret, akey, asecret


def count_tweet_text(text):
count = 0
if sys.version_info.major <= 2:
text = text.decode('utf-8')
for c in text:
if unicodedata.east_asian_width(c) in 'FWA':
count += 2
else:
count += 1
return count


def split_tweet_text(text, length=280):
texts = []
split_text = ''
count = 0
if sys.version_info.major <= 2:
text = text.decode('utf-8')
for c in text:
if count == 281:
# last word is zenkaku.
texts.append(split_text[:-1])
split_text = split_text[-1:]
count = 2
elif count == 280:
texts.append(split_text)
split_text = ''
count = 0
split_text += c
if unicodedata.east_asian_width(c) in 'FWA':
count += 2
else:
count += 1
if count != 0:
texts.append(split_text)
return texts
Loading