Repository: ali1234/vhs-teletext
Branch: master
Commit: f470629a3d5d
Files: 78
Total size: 341.5 KB
Directory structure:
gitextract_t4lypv78/
├── .coveragerc
├── .gitignore
├── HOW_IT_WORKS.md
├── LICENSE
├── README.md
├── TRAINING.md
├── examples/
│ ├── filter
│ ├── maze
│ ├── service
│ ├── template.t42
│ ├── terminal
│ ├── tti
│ └── video
├── misc/
│ ├── teletext-noscanlines.css
│ └── teletext.css
├── setup.py
├── teletext/
│ ├── __init__.py
│ ├── __main__.py
│ ├── celp.py
│ ├── charset.py
│ ├── cli/
│ │ ├── __init__.py
│ │ ├── celp.py
│ │ ├── clihelpers.py
│ │ ├── teletext.py
│ │ ├── training.py
│ │ └── vbi.py
│ ├── coding.py
│ ├── elements.py
│ ├── file.py
│ ├── finders.py
│ ├── gui/
│ │ ├── __init__.py
│ │ ├── classify.py
│ │ ├── decoder.py
│ │ ├── decoder.qml
│ │ ├── editor.py
│ │ ├── editor.ui
│ │ ├── qthelpers.py
│ │ ├── service.py
│ │ └── vbiplot.py
│ ├── image.py
│ ├── interactive.py
│ ├── mp.py
│ ├── packet.py
│ ├── parser.py
│ ├── pipeline.py
│ ├── printer.py
│ ├── service.py
│ ├── servicedir.py
│ ├── sigint.py
│ ├── spellcheck.py
│ ├── stats.py
│ ├── subpage.py
│ ├── ts.py
│ └── vbi/
│ ├── __init__.py
│ ├── clustering.py
│ ├── config.py
│ ├── line.py
│ ├── pattern.py
│ ├── patterncuda.py
│ ├── patternopencl.py
│ ├── training.py
│ └── viewer.py
└── tests/
├── __init__.py
├── test_cli.py
├── test_coding.py
├── test_elements.py
├── test_file.py
├── test_mp.py
├── test_packet.py
├── test_sigint.py
├── test_spellcheck.py
├── test_stats.py
├── test_subpage.py
└── vbi/
├── __init__.py
├── test_line.py
├── test_patterncuda.py
├── test_patternopencl.py
└── test_training.py
================================================
FILE CONTENTS
================================================
================================================
FILE: .coveragerc
================================================
[run]
source = teletext
================================================
FILE: .gitignore
================================================
/.idea
/.coverage
/venv
/*.vbi
*.pyc
/build
/dist
/MANIFEST
/*.egg-info
/vbi
/teletext/vbi/data/*-*
================================================
FILE: HOW_IT_WORKS.md
================================================
HOW IT WORKS
------------
Teletext is encoded as a non-return-to-zero signal with two levels representing
one and zero. This is a fancy way of saying that a line of teletext data is
a sequence of black and white "pixels" in the TV signal. Of course, since the
signal is analogue there are no individual pixels, the signal is continuous.
But you can imagine that there are pixels in the idealized "perfect" signal.
The problem of decoding teletext from a VHS recording is that VHS bandwidth is
lower than teletext bandwidth. This means that the signal is effectively low
pass filtered, which in terms of an image is equivalent to gaussian blurring.
There are methods for reversing gaussian blur, but they are designed to work
with general image data. In the case of teletext we only have black or white
levels, so these methods are not optimal. We can exploit the limitations on
the input in order to get a better result. We can also exploit information
about the protocol to further improve efficiency and accuracy.
When the black and white signal is blurred, the individual pixels are blurred
in to each other. This makes the signal unreadable using normal methods, because
instead of a clean sequence like "1010" you something close to "0.5 0.5 0.5 0.5".
But all is not lost, because a sequence like "1111" or "0000" will be the same
after blurring. So if you see a signal like "0.5 0.7 1.0 1.0" you can guess that
the original was probably "0 1 1 1" or "0 0 1 1".
There are 45 bytes in each teletext line, so the space of possible guesses is
2^(45*8) which is a very big number, which makes trying every guess completely
impractical. However there are ways to reduce this number:
FOUR RULES
----------
1. Nearly all bytes have a parity bit which means there are only 128 possible
combinations instead of 256.
2. Some bytes are hamming encoded. These have even fewer possible combinations.
3. The first three bytes in the signal are always the same. We can use this
to find the start of the signal in the sample data (it moves a bit in each
line, but the width is always the same.)
4. The protocol itself defines rules about which bytes are allowed in which
positions, reducing the problem space further.
TRAINING
--------
A known signal is recorded to a tape using a Raspberry Pi with rpi-teletext.
This signal is played back into the computer, which builds a table of convolved
-> original sequences.
DECONVOLUTION
-------------
The convolved training data can be compared to recorded tapes in order to determine
what the data originally was. The line is first resampled to 1 sample per "bit".
Then it is divided into "bytes". Each one is compared against the training
tables, including a few bits before and after. The closest match is the most
likely original signal.
This algorithm can be performed in parallel using CUDA or OpenCL. This allows
deconvolution to run in near realtime with a GTX 780.
See TRAINING.md for more.
SQUASHING
---------
The algorithm outputs lots of teletext packets, but they will still not be
perfect (even though they may be valid, they aren't necessarily correct.)
Since the teletext pages are broadcast on a loop, any recording of more than a
few minutes will have multiple copies of every packet. This means, if two packets
are received that only differ at a couple of bytes, they can be assumed to be the same.
The stream is first "paginated", ie split in to subpages.
All versions of the same subpage are compared, and for each byte, the most
frequent decoding is used so for example if you had these inputs:
HELLO
HELLP
MELLO
Then the result "HELLO" would be decoded, since those are the most frequent bytes
in this position. For this to work well, you need a lot of copies of every packet.
================================================
FILE: LICENSE
================================================
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Copyright (C)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Copyright (C)
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
.
================================================
FILE: README.md
================================================
This is a suite of tools for processing teletext signals recorded on VHS, as
well as tools for processing teletext packet streams. The software has only
been tested with bt8x8 capture hardware, but should work with any VBI capture
hardware with appropriate configuration.
This is the second rewrite of the original software. The old versions are
still available in the `v1` and `v2` branches of this repo, or from the
releases page.
You can see my collection of pages recovered with this software at:
https://al.zerostem.io/~al/teletext/
And more at:
http://www.teletextarchive.com
And:
http://archive.teletextart.co.uk/
INSTALLATION
------------
In order to use CUDA decoding you need to use the Nvidia proprietary driver.
To install with optional dependencies run:
pip3 install -e .[CUDA,spellcheck,viewer]
If CUDA or pyenchant are not available for your platform simply omit them
from the install command.
In order to use OpenCL you need to install pyopencl and the appropriate
opencl runtime for your card. Then run the deconvolve command with the '-O'
option.
In order for the output to be rendered correctly you need to use a specific
font and terminal:
sudo apt-get install tv-fonts rxvt-unicode
Then enable bitmap fonts in your X server:
cd /etc/fonts/conf.d
sudo rm 70-no-bitmaps.conf
sudo ln -s ../conf.avail/70-yes-bitmaps.conf .
After doing this you may need to rehash:
xset fp rehash
Finally open a terminal with the required font:
urxvt -fg white -bg black -fn teletext -fb teletext -geometry 41x25 +sb &
USAGE
-----
First capture VBI from VHS:
teletext record -d /dev/vbi0 > capture.vbi
Deconvolve the recording:
teletext deconvolve capture.vbi > stream.t42
Examine the headers to find services on the tape:
teletext filter -r 0 stream.t42
Split capture into services:
teletext filter --start --stop stream.t42 > stream-1.t42
Display all copies of a page in a stream:
teletext filter stream.t42 -p 100
Squash duplicate subpages, which reduces errors:
teletext squash stream.t42 > output.t42
Generate HTML pages from a stream:
teletext html output/ stream.t42
Interactively view the pages in a t42 stream:
teletext service stream.t42 | teletext interactive
In the interactive viewer you can type page numbers, or '.' for hold.
Run each command with '--help' for a complete list of options.
================================================
FILE: TRAINING.md
================================================
Training
--------
1. Record the training signal to a tape:
```
teletext training | raspi-teletext -
```
2. Record it back to `training.vbi`.
3. Run the following script to process the training file into patterns:
```
#!/bin/sh
SPLITS=$(mktemp -d -p .)
teletext training split $SPLITS training.vbi
teletext training squash $SPLITS training.dat
teletext training build -m full -b 4 20 training.dat full.dat
teletext training build -m parity -b 6 20 training.dat parity.dat
teletext training build -m hamming -b 1 20 training.dat hamming.dat
cp full.dat parity.dat hamming.dat ~/Source/vhs-teletext/teletext/vbi/data/
echo $SPLITS
```
Theory
------
The idea behind training is to record a known teletext signal on to
tape and then play it back into the computer in the same way as you
would when recovering a tape. Then the original and observed signal
can be compared to build a database of patterns.
To make sure we can identify the degraded training packets, each one
has an ID and checksum. These are encoded so that each bit of data is
three bits wide in the output. This makes recovery of the original
trivial. There are also fixed bytes which can be used to help with
alignment.
We want to fit the most possible patterns into the least possible
tape. A De Bruijn sequence is used to do this. This is defined as the
shortest possible sequence which contains every sequence of input
characters (0 and 1 in this case) up to length N.
We use the De Bruijn sequence [2, 24]. This means it contains every
possible sequence of 1 or 0 of length 24 bits, which is about 16
million patterns. The ID field stores an offset into this sequence.
When recording it is possible for a run of whole frames to be lost,
so we do not simply display the whole De Bruijn sequence from start
to finish. Instead, for each packet, we add a prime number to the
offset and modulo the sequence length. This way every part of the
sequence is shown multiple times, and even a long run of frame drops
is unlikely to cause total loss of any part of the pattern.
After recording the signal back into the computer it is sliced into
patterns representing 24 bits of data. For a specific 24 bit pattern
there will be multiple slices in the signal. An average is taken of
every occurence and saved along with the original data it represents.
This is the intermediate training data.
Finally the pattern files are built. A pattern is describe like this:
1. Number of bits to match before.
2. Set of possible bytes to match.
3. Number of bits to match after.
So for example, the parity data file is like this:
build_pattern(args.parity, 'parity.dat', 4, 18, parity_set)
Means:
1. Match 4 bits before.
2. Match any byte with odd parity. (128 possibilities/7 bits)
3. Match 3 bits after.
giving 14 bits total, or 16384 patterns.
To build the pattern data the intermediate data is processed and any
pattern which matches the criteria is added into a list. Then the
average for each list is taken. That is the final pattern we will
match against.
================================================
FILE: examples/filter
================================================
#!/usr/bin/env python3
# An example of making a customized filter in Python.
# This is almost a direct copy-paste of the filter subcommand
# tweaked to run standalone, and with an extra filter that
# removes non-page packets.
import click
from teletext.cli.clihelpers import packetreader, packetwriter, paginated
from teletext import pipeline
@click.command()
@packetwriter
@paginated()
@click.option('--pagecount', 'n', type=int, default=0, help='Stop after n pages. 0 = no limit. Implies -P.')
@click.option('-k', '--keep-empty', is_flag=True, help='Keep empty packets in the output.')
@packetreader()
def filter(packets, pages, subpages, paginate, n, keep_empty):
"""Demultiplex and display t42 packet streams."""
if n:
paginate = True
if not keep_empty:
packets = (p for p in packets if not p.is_padding())
# customize the filtering:
packets = (p for p in packets if p.mrag.row not in [29, 30, 31])
if paginate:
for pn, pl in enumerate(pipeline.paginate(packets, pages=pages, subpages=subpages), start=1):
yield from pl
if pn == n:
return
else:
yield from packets
if __name__ == '__main__':
filter()
================================================
FILE: examples/maze
================================================
#!/usr/bin/env python
import random
import click
import numpy as np
from PIL import Image, ImageDraw
from teletext.cli.clihelpers import packetwriter
from teletext.service import Service
from teletext.subpage import Subpage
class Maze:
directions = [(0, 1), (1, 0), (0, -1), (-1, 0)]
def __init__(self, width=8, height=6):
self.width = width
self.height = height
self.data = np.zeros((2*height-1, 2*width-1), dtype=np.uint8)
self.data[::2, ::2] = 1
self.connect(0, 0, 0, 1)
self.connect(self.width-1, self.height-1, 0, -1)
self.generate(self.width//2, self.height//2, {(0, 0), (self.width-1, self.height-1)})
def valid(self, px, py):
return 0 <= px < self.width and 0 <= py < self.height
def connect(self, px, py, dx, dy):
self.data[(2*py)+dy, (2*px)+dx] = 1
def generate(self, x, y, visited):
visited.add((x, y))
for d in random.sample(self.directions, 4):
print(x, y, d)
nx, ny = x+d[0], y+d[1]
if self.valid(nx, ny) and (nx, ny) not in visited:
self.connect(x, y, *d)
self.generate(nx, ny, visited)
def connections(self, px, py, dx, dy):
ldx, ldy = -dy, dx
rdx, rdy = dy, -dx
left = []
right = []
while True:
if self.valid(px+ldx, py+ldy):
left.append(self.data[(2*py)+ldy, (2*px)+ldx])
else:
left.append(0)
if self.valid(px+rdx, py+rdy):
right.append(self.data[(2*py)+rdy, (2*px)+rdx])
else:
right.append(0)
if self.valid(px+dx, py+dy) and self.data[(2*py)+dy, (2*px)+dx]:
px += dx
py += dy
else:
break
return left, right
def bitmap(self, left, right):
w, h = 39*2, 23*3
hh = h - 1
ww = w - 1
bitmap = Image.new("1", (w, h))
def ox(o, l):
o = min(o, (ww//2)-4)
return o + 4 if l else ww-o-4
def oy(o, t):
o = min(o, (hh//2))
return o if t else hh-o
def draw_p(x1, y1, x2, y2, l):
x1 = ox(x1, l)
x2 = ox(x2, l)
for t in (True, False):
y1 = oy(y1, t)
y2 = oy(y2, t)
draw.line((x1, y1, x2, y2), fill=1)
def draw_h(x1, x2, y, l):
return draw_p(x1, y, x2, y, l)
def draw_d(xy1, xy2, l):
return draw_p(xy1, xy1, xy2, xy2, l)
def draw_v(xy, l):
x = ox(xy, l)
draw.line((x, oy(xy, True), x, oy(xy, False)), fill=1)
def draw_side(n, o1, o2, o3, l, p):
if p:
draw_h(o2, o3, o3, l)
draw_v(o2, l)
if n+1 < len(left):
draw_v(o3, l)
else:
draw_d(o2, o3, l)
draw_d(o1, o2, l)
def calc_o(n):
return (n * 22) - 16 - ((n*(n+1)*2))
draw = ImageDraw.Draw(bitmap)
for n, (l, r) in enumerate(zip(left, right)):
o1 = calc_o(n)
o2 = o1 + max(0, 6 - n)
o3 = max(o1, calc_o(n+1))
draw_side(n, o1, o2, o3, True, l)
draw_side(n, o1, o2, o3, False, r)
o = calc_o(len(left))
draw_h(o, ww, o, True)
draw_h(o, ww, o, False)
if not left[-1]:
draw_v(o, True)
if not right[-1]:
draw_v(o, False)
return np.array(bitmap)
def view_to_mrag(self, px, py, dx, dy):
return self.directions.index((dx, dy)) + 2, py + (px << 4)
def view(self, px, py, dx, dy):
m, p = self.view_to_mrag(px, py, dx, dy)
page = Subpage(prefill=True, magazine=m)
page.header.page = p
page.header.control = 0
left, right = self.connections(px, py, dx, dy)
page.displayable.place_bitmap(np.array(self.bitmap(left[:5], right[:5])))
# cheat mode / debug dump
# put revealo maps on every wall
if True and len(left) == 1:
x = (38 - self.data.shape[1])//2
y = (23 - self.data.shape[0])//2
for n, r in enumerate(self.data[::-1]):
page.displayable.place_string('\x07\x18' + ''.join('.' if p else ' ' for p in r) + '\x17', x=x, y=y+n)
dn = ['^', '>', 'v', '<'][self.directions.index((dx, dy))]
page.displayable.place_string(dn, x=px*2+x+2, y=self.data.shape[0]+y-1-(2*py))
# create fastext links
# TODO: add some helpers to make this easier
page.displayable.place_string('\x01TurnLeft\x02StepForward\x03TurnRight\x06StepBack', y=23)
page.init_packet(27, magazine=m)
page.packet(27).fastext.dc = 0
page.packet(27).fastext.control = 0xf
lm, lp = self.view_to_mrag(px, py, -dy, dx)
page.packet(27).fastext.links[0].magazine = lm
page.packet(27).fastext.links[0].page = lp
lm, lp = self.view_to_mrag(px, py, dy, -dx)
page.packet(27).fastext.links[2].magazine = lm
page.packet(27).fastext.links[2].page = lp
if self.valid(px+dx, py+dy) and self.data[(2*py)+dy, (2*px)+dx]:
lm, lp = self.view_to_mrag(px+dx, py+dy, dx, dy)
else:
lm, lp = m, p
page.packet(27).fastext.links[1].magazine = lm
page.packet(27).fastext.links[1].page = lp
if self.valid(px-dx, py-dy)and self.data[(2*py)-dy, (2*px)-dx]:
lm, lp = self.view_to_mrag(px-dx, py-dy, dx, dy)
else:
lm, lp = m, p
page.packet(27).fastext.links[3].magazine = lm
page.packet(27).fastext.links[3].page = lp
return page
def map(self):
page = Subpage(prefill=True, magazine=1)
page.header.page = 0
page.header.control = 0
page.displayable.place_string("\x0d You are trapped in a maze! ", y=2)
page.displayable.place_string("\x0d Use the fastext buttons to move. ", y=4)
page.displayable.place_string("\x0d Press reveal for a hint: ", y=6)
page.displayable.place_string('\x12\x18\x24', x=18+(self.data.shape[1]//4), y=8)
page.displayable.place_bitmap(self.data[::-1], x=19-(self.data.shape[1]//4), y=9, conceal=True)
page.displayable.place_string('\x11\x18\x21', x=17-(self.data.shape[1]//4), y=10+(self.data.shape[0]//3))
page.displayable.place_string("\x0d Good luck! ", y=14)
page.displayable.place_string("\x0d Press any button to begin. ", y=16)
page.displayable.place_string("\x01 Begin \x02 Begin \x03 Begin \x06 Begin ", y=23)
page.init_packet(27, magazine=1)
page.packet(27).fastext.dc = 0
page.packet(27).fastext.control = 0xf
for n in range(4):
page.packet(27).fastext.links[n].magazine = 2
page.packet(27).fastext.links[n].page = 0
return page
def service(self):
service = Service(replace_headers=True, title="Maze!")
service.insert_page(self.map())
for y in range(self.height):
for x in range(self.width):
for d in self.directions:
service.insert_page(self.view(x, y, *d))
return service
@click.command()
@packetwriter
def maze():
return Maze().service()
if __name__ == '__main__':
maze()
================================================
FILE: examples/service
================================================
#!/usr/bin/env python3
# An example of generating a service from scratch.
import sys
from teletext.service import Service
from teletext.subpage import Subpage
# Create a subpage
subpage = Subpage(prefill=True)
# Fill with clock cracker
subpage.displayable[:,0::2] = 0xfe
subpage.displayable[:,1::2] = 0x7f
subpage.displayable[:,0] = 0x20
# Put a message in the middle
subpage.displayable.place_string('Hello World', 15, 11)
# Create the service
service = Service(replace_headers=True)
# Add the subpage to the service.
service.magazines[1].pages[0].subpages[0] = subpage
# Set magazine name and number
service.magazines[1].title = 'Example '
# Broadcast it forever
while True:
# Stream some packets
for packet in service.packets(32):
sys.stdout.buffer.write(packet.bytes)
# Modify the service if required
================================================
FILE: examples/terminal
================================================
#!/usr/bin/env python3
import datetime
import os
import pty
import select
import shlex
import time
import click
import pyte
from teletext.cli.clihelpers import packetwriter
from teletext.subpage import Subpage
@click.command()
@click.argument("command", type=str)
@packetwriter
def terminal(command):
"""
Runs a command in a simulated terminal and outputs a packet stream on page 100.
"""
columns, lines = 40, 24
# run the command
p_pid, p_fd = pty.fork()
if p_pid == 0: # Child.
argv = shlex.split(command)
env = dict(TERM="linux", LC_ALL="en_GB.UTF-8",
COLUMNS=str(columns), LINES=str(lines))
os.execvpe(argv[0], argv, env)
# set up virtual terminal
screen = pyte.Screen(columns, lines)
screen.set_mode(pyte.modes.LNM)
screen.write_process_input = lambda data: p_fd.write(data.encode())
stream = pyte.ByteStream()
stream.attach(screen)
# set up page
page = Subpage(prefill=True, magazine=1)
page.header.control = 1<<4
page.header.page = 0
# init fastext so we can use row 24
page.init_packet(27, magazine=1)
page.packet(27).fastext.dc = 0
page.packet(27).fastext.control = 0xf
# generation loop
prev_refresh = time.time()
try:
while True:
r, w, x = select.select([p_fd], [], [], 1.0)
if p_fd in r:
stream.feed(os.read(p_fd, 65536))
dt = datetime.datetime.now().strftime(" %a %d %b\x03%H:%M/%S")
page.header.displayable.place_string('%-12s' % (command[:12]) + dt)
for y in screen.dirty:
line = screen.buffer[y]
data = ''.join(char.data for char in (line[x] for x in range(screen.columns)))
page.packet(y+1).displayable.place_string(data)
now = time.time()
elapsed = now - prev_refresh
if elapsed > 1.0:
prev_refresh = now
send_lines = range(lines)
else:
send_lines = screen.dirty
yield page.packet(0)
for y in send_lines:
yield page.packet(y+1)
yield page.packet(27)
screen.dirty.clear()
except OSError:
pass
if __name__ == '__main__':
terminal()
================================================
FILE: examples/tti
================================================
#!/usr/bin/env python3
import json
import re
import requests
from PIL import Image
import numpy as np
from teletext.subpage import Subpage
def get_sensors():
url = "http://hms.nottinghack.org.uk/api/spaceapi"
dat = json.loads(requests.get(url).text)
return {s['location']: s["value"] for s in dat['sensors']['temperature']}
def colour(t):
if t < 10:
return '\x06'
elif t < 20:
return '\x02'
elif t < 30:
return '\x04'
else:
return '\x01'
def clean_name(s):
s = s.split('/')[0].replace('-LLAP', '').replace('G5-', '')
return re.sub(r'([^\s-])([A-Z])', r'\1 \2', s)
def tti():
with open('template.t42', 'rb') as f:
page = Subpage.from_file(f)
page.header.page = 0
page.header.control = 0
layout = (
('Airlock', 'ComfyArea-LLAP'),
('Studio-LLAP', 'CraftRoom-LLAP'),
('Kitchen-LLAP', 'Workshop-LLAP'),
('ClassRoom', 'G5-BlueRoom-LLAP'),
)
i = Image.open("sensors.png").convert(mode="1")
page.displayable.place_bitmap(np.array(i), 16, 2, 0x13)
sensors = get_sensors()
for n, l in enumerate(layout):
clean_names = (clean_name(s)[:10] + ':' for s in l)
string = ' \x07'.join(f'{c:11s}{colour(sensors[s])}{sensors[s]:4.1f}C' for c, s in zip(clean_names, l))
page.displayable.place_string('\x0d'+string, 1, (3*n)+8) # 0d = double height
page.displayable.place_string('\x02\x1d\x04Temperature readings from the space', 0, 21)
page.displayable.place_string('\x02\x1d\x04See P123 for info about the heating', 0, 22)
page.displayable.place_string('\x01Main Index \x02News \x03Events \x06Tools', 0, 23)
with open('P100.tti', 'wb') as f:
f.write(page.to_tti())
print("https://zxnet.co.uk/teletext/editor/#" + page.url)
if __name__ == '__main__':
tti()
================================================
FILE: examples/video
================================================
#!/usr/bin/env python3
import datetime
import socket
import click
import cv2
import srt
from teletext.cli.clihelpers import packetwriter
from teletext.subpage import Subpage
@click.command()
@click.argument('videofile', type=click.Path(readable=True))
@click.option('-s', '--subs', type=click.File('r'), help="Subtitle file (srt)")
@click.option('--start', type=int, default=0, help="Start at frame N")
@click.option('--end', type=int, default=None, help="End at frame N")
@packetwriter
def video(videofile, subs, start, end):
"""
Converts a video into a page stream. Also supports SRT subtitles.
Can set a start and end position in frames. Also works with images
because OpenCV treats them as videos with one frame.
"""
hostname = socket.gethostname()[:12]
# Set up page
page = Subpage(prefill=True, magazine=1)
page.header.control = 1<<4
page.header.page = 0
# init fastext so we can use row 24
page.init_packet(27, magazine=1)
page.packet(27).fastext.dc = 0
page.packet(27).fastext.control = 0xf
# Creating a VideoCapture object to read the video
cap = cv2.VideoCapture(videofile)
cap.set(cv2.CAP_PROP_POS_FRAMES, start)
fps = cap.get(cv2.CAP_PROP_FPS)
frameno = start
sub = None
if subs:
subs = srt.parse(subs.read())
sub = next(subs)
# Loop until the end of the video
while (cap.isOpened() and (end is None or frameno <= end)):
# Capture frame-by-frame
ret, frame = cap.read()
if not ret: # eof
break
frameno += 1
frame = cv2.resize(frame, (39*2, 24*3), fx = 0, fy = 0,
interpolation = cv2.INTER_CUBIC)
# conversion of BGR to grayscale is necessary to apply this operation
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
# adaptive thresholding to use different threshold
# values on different regions of the frame.
thresha = cv2.adaptiveThreshold(gray, 1, cv2.ADAPTIVE_THRESH_MEAN_C,
cv2.THRESH_BINARY, 3, 2)
threshb = cv2.threshold(gray, 16, 255, cv2.THRESH_BINARY)[1]
thresh = (thresha * threshb) > 0
dt = datetime.datetime.now().strftime(" %a %d %b\x03%H:%M/%S")
page.header.displayable.place_string('%-12s' % (hostname) + dt)
page.displayable.place_bitmap(thresh)
if sub:
seconds = datetime.timedelta(seconds = frameno / fps)
try:
while seconds > sub.end:
sub = next(subs)
if sub and seconds >= sub.start:
x = sub.content.split('\n')
w = len(max(x, key=len))
o = max(min(38 - w, 2), 0)
for n, l in enumerate(x):
page.packet(n+21).displayable.place_string(('\x06' + l + '\x17')[:(40-o)], x=o)
except StopIteration:
sub = None
yield from page.packets
# release the video capture object
cap.release()
if __name__ == '__main__':
video()
================================================
FILE: misc/teletext-noscanlines.css
================================================
body {background: black;}
a {color: inherit; text-decoration: none;}
a:hover {color: orange; } a:active {color: red;}
:focus {outline: 0;}
@font-face {font-family:teletext2; src:url('teletext2.ttf')}
@font-face {font-family:teletext4; src:url('teletext4.ttf')}
.subpage {
position:relative; top:0; left:0;
float:left; white-space: pre; color:white; background:black;
font-family: teletext2; font-size:30px; line-height: 100%;
border: solid black 10px;
border-bottom: solid black 20px;
text-shadow: 0 0 0.05em;
}
.subpage{
filter:blur(0.5px) brightness(120%);
}
.row{
display: flex;
}
.subpage:after{
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
z-index:10;
pointer-events:none;
}
.f0{color:black;} .f1{color:red;} .f2{color:#00ff00;} .f3{color:yellow;}
.f4{color:blue;} .f5{color:magenta;} .f6{color:cyan;} .f7{color:white;}
.b0{background:black;} .b1{background:red;} .b2{background:#00ff00;} .b3{background:yellow;}
.b4{background:blue;} .b5{background:magenta;} .b6{background:cyan;} .b7{background:white;}
.dh{font-family: teletext4; font-size:200%; line-height:100%;}
.fl{text-decoration: blink}
================================================
FILE: misc/teletext.css
================================================
@import url("teletext-noscanlines.css");
/* scanlines */
.subpage {
/* This gradient looks worse but can be rendered at 2 (physical) pixels. */
--gradient-small: repeating-linear-gradient(
to top,
rgba(0,0,0,0.8) 0px,
transparent 1px,
transparent 2px,
rgba(0,0,0,0.8) 3px
);
/* This gradient looks better but can't be rendered at 2 (physical) pixels. */
--gradient-big: repeating-linear-gradient(
to top,
rgba(0,0,0,1),
transparent 25%,
transparent 75%,
rgba(0,0,0,1) 100%
);
}
/* Small size: 245px x 250px, 1 x 1 scaling */
@media (max-width: 660px) {
.subpage { font-size:10px; }
.subpage:after { background-image: none; }
}
/* Medium size: 490px x 500px, 2 x 2 scaling, scanlines possible */
@media (min-width: 660px) and (max-width: 980px) {
.subpage { font-size:20px; }
.subpage:after{
background-size: 2px 2px;
background-image: var(--gradient-small);
}
/* Use the nicer gradient on hidpi/retina displays. */
/* This is only necessary when 2x2 scaling. */
@media (min-resolution: 2dppx) {
.subpage:after{
background-image: var(--gradient-big);
}
}
}
/* Large size: 735 x 750px, 3 x 3 scaling, scanlines possible */
@media (min-width: 980px) {
.subpage { font-size:30px; }
.subpage:after{
/* At 3x3 scaling and above, the big gradient looks better. */
background-image: var(--gradient-big);
background-size: 3px 3px;
}
}
================================================
FILE: setup.py
================================================
from setuptools import setup
setup(
name='teletext',
version='3.1.99',
author='Alistair Buxton',
author_email='a.j.buxton@gmail.com',
url='http://github.com/ali1234/vhs-teletext',
packages=['teletext', 'teletext.vbi', 'teletext.cli', 'teletext.gui'],
package_data={
'teletext.vbi': [
'data/debruijn.dat',
'data/vhs/parity.dat',
'data/vhs/hamming.dat',
'data/vhs/full.dat',
'data/betamax/parity.dat',
'data/betamax/hamming.dat',
'data/betamax/full.dat'
],
'teletext.gui': [
'decoder.qml',
'editor.ui',
]
},
entry_points={
'console_scripts': [
'teletext = teletext.cli.teletext:teletext',
],
'gui_scripts': [
'ttviewer = teletext.gui.editor:main',
],
},
install_requires=[
'numpy<2', 'scipy', 'matplotlib', 'click', 'tqdm', 'pyzmq', 'watchdog', 'pyserial',
'windows-curses;platform_system=="Windows"',
],
extras_require={
'spellcheck': ['pyenchant'],
'CUDA': ['pycuda'],
'OpenCL': ['pyopencl'],
'viewer': ['PyOpenGL'],
'profiler': ['plop'],
'qt': ['PyQt5'],
'audio': ['spectrum', 'miniaudio'],
}
)
================================================
FILE: teletext/__init__.py
================================================
================================================
FILE: teletext/__main__.py
================================================
from teletext.cli.teletext import teletext
if __name__ == '__main__':
teletext()
================================================
FILE: teletext/celp.py
================================================
import numpy as np
import matplotlib.pyplot as plt
from spectrum import lsf2poly
import numpy as np
from scipy.signal import lfilter
from collections import deque
from tqdm import tqdm
hamming7_dec = np.array([
[ 0x00, 0x10, 0x10, 0x14, 0x10, 0x11, 0x18, 0x12 ],
[ 0x10, 0x11, 0x13, 0x19, 0x11, 0x01, 0x15, 0x11 ],
[ 0x10, 0x1a, 0x13, 0x12, 0x16, 0x12, 0x12, 0x02 ],
[ 0x13, 0x17, 0x03, 0x13, 0x1b, 0x11, 0x13, 0x12 ],
[ 0x10, 0x14, 0x14, 0x04, 0x16, 0x1c, 0x15, 0x14 ],
[ 0x1d, 0x17, 0x15, 0x14, 0x15, 0x11, 0x05, 0x15 ],
[ 0x16, 0x17, 0x1e, 0x14, 0x06, 0x16, 0x16, 0x12 ],
[ 0x17, 0x07, 0x13, 0x17, 0x16, 0x17, 0x15, 0x1f ],
[ 0x10, 0x1a, 0x18, 0x19, 0x18, 0x1c, 0x08, 0x18 ],
[ 0x1d, 0x19, 0x19, 0x09, 0x1b, 0x11, 0x18, 0x19 ],
[ 0x1a, 0x0a, 0x1e, 0x1a, 0x1b, 0x1a, 0x18, 0x12 ],
[ 0x1b, 0x1a, 0x13, 0x19, 0x0b, 0x1b, 0x1b, 0x1f ],
[ 0x1d, 0x1c, 0x1e, 0x14, 0x1c, 0x0c, 0x18, 0x1c ],
[ 0x0d, 0x1d, 0x1d, 0x19, 0x1d, 0x1c, 0x15, 0x1f ],
[ 0x1e, 0x1a, 0x0e, 0x1e, 0x16, 0x1c, 0x1e, 0x1f ],
[ 0x1d, 0x17, 0x1e, 0x1f, 0x1b, 0x1f, 0x1f, 0x0f ],
], dtype=np.uint8)
class LtpCodebook:
def __init__(self, subframe_length):
#self.buffer = np.zeros((147, subframe_length), dtype=np.double)
#self.pos = 0
self.buffer = deque(maxlen=147)
def insert(self, subframe):
self.buffer.extendleft(subframe)
def get(self, lag):
try:
return self.buffer[lag + 20]
except IndexError:
return 0
class CELPStats:
def __init__(self, decoder):
self.decoder = decoder
def __str__(self):
result = f', L:{self.decoder.lsf_error:.0f}%, VG:{self.decoder.vector_gain_error:.0f}%'
# reset the error counters
self.decoder.lsf_errors = 0
self.decoder.vector_gain_errors = 0
self.decoder.subframes = 0
return result
class CELPDecoder:
widths = np.array([
0,
3, 4, 4, 4, 4, 4, 4, 4, 3, 3, # 37 bytes - 10 x LPC params of (unknown?) variable size
5, 5, 5, 5, # 4x5 = 20 bytes - pitch gain (LTP gain)
5, 5, 5, 5, # 4x5 = 20 bytes - vector gain
7, 7, 7, 7, # 4x7 = 28 bytes - pitch index (LTP lag)
8, 8, 8, 8, # 4x8 = 32 bytes - vector index
3, 3, 3, 3, # 4x3 = 12 bytes - error correction for vector gains?
3, # 3 bytes - always zero (except for recovery errors)
])
offsets = np.cumsum(widths)
lsf_vector_quantizers = {
# Source Reliant Error Control For Low Bit Rate Speech Communications, Ong, p103
'ong': np.array([
[ 178, 218, 236, 267, 293, 332, 378, 420, 0, 0, 0, 0, 0, 0, 0, 0,],
[ 210, 235, 265, 295, 325, 360, 400, 440, 480, 520, 560, 610, 670, 740, 810, 880,],
[ 420, 460, 500, 540, 585, 640, 705, 775, 850, 950, 1050, 1150, 1250, 1350, 1450, 1550,],
[ 752, 844, 910, 968, 1016, 1064, 1110, 1155, 1202, 1249, 1295, 1349, 1409, 1498, 1616, 1808,],
[1041, 1174, 1274, 1340, 1407, 1466, 1514, 1559, 1611, 1658, 1714, 1773, 1834, 1906, 2008, 2166,],
[1438, 1583, 1671, 1740, 1804, 1855, 1905, 1947, 1988, 2034, 2081, 2135, 2193, 2267, 2369, 2476,],
[2005, 2115, 2176, 2222, 2260, 2297, 2333, 2365, 2394, 2427, 2463, 2501, 2551, 2625, 2728, 2851,],
[2286, 2410, 2480, 2528, 2574, 2613, 2650, 2689, 2723, 2758, 2790, 2830, 2879, 2957, 3049, 3197,],
[2775, 2908, 3000, 3086, 3159, 3234, 3331, 3453, 0, 0, 0, 0, 0, 0, 0, 0,],
[3150, 3272, 3354, 3415, 3473, 3531, 3580, 3676, 0, 0, 0, 0, 0, 0, 0, 0,],
]),
# Speech Coding in Private and Broadcast Networks, Suddle, p121
# note the transposition of first 8 values in column 7/8
'suddle': np.array([
[ 143, 182, 214, 246, 284, 329, 389, 475, 0, 0, 0, 0, 0, 0, 0, 0,],
[ 211, 252, 285, 317, 349, 383, 419, 458, 503, 554, 608, 665, 731, 809, 912, 1072,],
[ 402, 470, 522, 571, 621, 671, 724, 778, 835, 902, 979, 1065, 1147, 1241, 1357, 1517,],
[ 617, 732, 819, 885, 944, 1001, 1060, 1121, 1186, 1260, 1342, 1425, 1514, 1613, 1723, 1885,],
[ 981, 1081, 1172, 1254, 1329, 1403, 1473, 1539, 1609, 1679, 1753, 1826, 1908, 1998, 2106, 2236,],
[1334, 1446, 1539, 1626, 1697, 1763, 1828, 1890, 1954, 2019, 2087, 2160, 2238, 2328, 2420, 2526,],
[1830, 1959, 2056, 2134, 2198, 2254, 2303, 2349, 2397, 2448, 2500, 2560, 2632, 2715, 2823, 2966,],
[2247, 2361, 2434, 2496, 2550, 2600, 2647, 2694, 2742, 2791, 2846, 2904, 2966, 3049, 3155, 3256,],
[2347, 2481, 2583, 2674, 2767, 2874, 3005, 3202, 0, 0, 0, 0, 0, 0, 0, 0,],
[3140, 3246, 3326, 3395, 3458, 3524, 3601, 3709, 0, 0, 0, 0, 0, 0, 0, 0,],
]),
}
lsf_weights = np.array([1/8, 3/8, 5/8, 7/8])
vec_gain_quantizers = {
# Suddle, chapter 7
'audetel': np.array([
-1996, -1306, -990, -780, -628, -510, -418, -336,
-268, -204, -148, -96, -54, -20, -6, -2,
2, 6, 20, 54, 96, 148, 204, 268,
336, 418, 510, 628, 780, 990, 1306, 1996,
]),
# Suddle, chapter 5
'unknown': np.array([
-1100, -850, -650, -510, -415, -335, -275, -220,
-175, -135, -98, -65, -35, -12, -3, -1,
1, 3, 12, 35, 65, 98, 135, 175,
220, 275, 335, 415, 510, 650, 850, 1100,
]),
}
ltp_gain_quantization = np.array([
-0.993, -0.831, -0.693, -0.555, -0.414, -0.229, 0.0, 0.193,
0.255, 0.368, 0.457, 0.531, 0.601, 0.653, 0.702, 0.745,
0.780, 0.816, 0.850, 0.881, 0.915, 0.948, 0.983, 1.020,
1.062, 1.117, 1.193, 1.289, 1.394, 1.540, 1.765, 1.991,
])
def __init__(self, lsf_lut='suddle', vec_gain_lut='audetel', sample_rate=8000):
self.lsf_lut = lsf_lut
self.lsf_vector_quantization = self.lsf_vector_quantizers[lsf_lut]
self.vec_gain_quantization = self.vec_gain_quantizers[vec_gain_lut]
self.sample_rate = sample_rate
self.subframe_length = sample_rate // 200
self.pos = 0
self.vector_gain_errors = 0
self.lsf_errors = 0
self.subframes = 0
@property
def lsf_error(self):
return 100.0 * self.lsf_errors / self.subframes
@property
def vector_gain_error(self):
return 100.0 * self.vector_gain_errors / self.subframes
def stats(self):
return CELPStats(self)
def vector_parity(self, data, parity):
hamm = hamming7_dec[data>>1, parity]
self.vector_gain_errors += np.sum(hamm >> 4)
return ((hamm&0xf)<<1)|(data&1)
def decode_params(self, raw_frame):
"""Extracts the parameters from the raw packet according to offsets and widths."""
bits = np.unpackbits(raw_frame, bitorder='little')
decoded_frame = np.empty((30, ), dtype=np.uint8)
for n in range(len(self.offsets)-2):
slice = bits[self.offsets[n]:self.offsets[n+1]]
decoded_frame[n] = np.packbits(slice, bitorder='little')
lsf = self.lsf_vector_quantization[np.arange(10), decoded_frame[:10]]
if np.any(np.diff(lsf) < 0):
self.lsf_errors += 4
pitch_gain = self.ltp_gain_quantization[decoded_frame[10:14]]
#vector_gain = self.vec_gain_quantization[decoded_frame[14:18]] # no hamming correction
vector_gain = self.vec_gain_quantization[self.vector_parity(decoded_frame[14:18], decoded_frame[26:30])]
pitch_idx = decoded_frame[18:22]
vector_idx = decoded_frame[22:26]
self.subframes += 4
return lsf, pitch_gain, vector_gain, pitch_idx, vector_idx
# wave we're going to play through the filter
wave = np.random.uniform(-1, 1, size=(8000, ))
def apply_lpc_filter(self, lsf, signal):
"""Convert line spectrum frequencies to a filter and apply to the signal."""
a = lsf2poly(sorted(lsf * 2 * np.pi / self.sample_rate))
result, self.last_z = lfilter([1], a, signal, zi=self.last_z)
return result
def generate_audio(self, raw_frame):
"""Generate an audio frame from a raw frame."""
lsf, pitch_gain, vector_gain, pitch_idx, vector_idx = self.decode_params(raw_frame)
# interpolate the LSFs
if self.last_lsf is None:
sub_lsf = np.repeat(lsf, 4).reshape(10, 4).T
else:
sub_lsf = (self.last_lsf[np.newaxis, :] * self.lsf_weights[::-1, np.newaxis]) + (lsf[np.newaxis, :] * self.lsf_weights[:, np.newaxis])
frame = np.empty((self.subframe_length * 4,), dtype=np.double)
subframe_buf = np.empty((self.subframe_length), dtype=np.double)
for subframe in range(4):
for n in range(self.subframe_length):
self.pos += 1
subframe_buf[n] = (self.wave[self.pos % self.sample_rate] * vector_gain[subframe]) + (self.ltp_codebook.get(pitch_idx[subframe] - n) * pitch_gain[subframe])
self.ltp_codebook.insert(subframe_buf)
p = subframe * self.subframe_length
frame[p:p + self.subframe_length] = self.apply_lpc_filter(sub_lsf[subframe], subframe_buf)
self.last_lsf = lsf
return np.clip(frame * 0.5, -32767, 32767).astype(np.int16)
def decode_packet_stream(self, packets, frame=None):
"""Decode an entire packet stream, yielding audio frames."""
self.last_lsf = None
self.last_z = np.zeros((10, ))
self.ltp_codebook = LtpCodebook(self.subframe_length)
for p in packets:
if frame is None or frame == 0:
yield self.generate_audio(p._array[4:23])
if frame is None or frame == 1:
yield self.generate_audio(p._array[23:42])
def stream_pcm(self, packets, frame, device):
src = self.decode_packet_stream(packets, frame)
buf = []
buflen = 0
required_samples = yield b"" # generator initialization
for frame in src:
buf.append(frame)
buflen += frame.shape[0]
if buflen >= required_samples:
tmp = np.concatenate(buf)
buf = [tmp[required_samples:]]
buflen = buf[0].shape[0]
required_samples = yield tmp[:required_samples].tobytes()
device.__running = False
def play(self, packets, frame=None):
"""Play a packet stream."""
import miniaudio
import time
with miniaudio.PlaybackDevice(output_format=miniaudio.SampleFormat.SIGNED16,
nchannels=1, sample_rate=self.sample_rate,
buffersize_msec=500) as device:
device.__running = True
stream = self.stream_pcm(packets, frame, device)
next(stream) # start the generator
device.start(stream)
while device.__running:
time.sleep(0.1)
def convert(self, output, packets, frame=None):
import wave
wf = wave.open(output, 'wb')
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(8000)
for frame in self.decode_packet_stream(packets, frame):
wf.writeframes(frame.tobytes())
wf.close()
@classmethod
def plot(cls, packets):
"""Plot statistics of the raw packets."""
datas = []
for p in packets:
datas.append(p._array[4:])
datas = np.concatenate(datas)
data = np.unpackbits(datas.reshape(-1, 2, 19), bitorder='little').reshape(-1, 2, 152)
d1 = np.sum(data, axis=0)
p = np.arange(152)
fig, ax = plt.subplots(6, 2)
# plot the bit counts (top plots)
for n in range(cls.offsets.shape[0]-1):
s = slice(cls.offsets[n], cls.offsets[n + 1])
ax[0][0].bar(p[s], d1[0][s], 0.8)
ax[0][1].bar(p[s], d1[1][s], 0.8)
# plot vector and pitch parameters over time
for x in range(2):
frame = data[:, x, :]
for y, o in enumerate([10, 14, 18, 22], start=2):
bits = frame[:,cls.offsets[o]:cls.offsets[o+4]].reshape(-1, cls.widths[o+1])
a = np.packbits(bits, axis=-1, bitorder='little').flatten()
ax[y][x].plot(a[:10000], linewidth=0.1)
if y == 3:
hm = frame[:, cls.offsets[26]:cls.offsets[30]].reshape(-1, cls.widths[27])
hm = np.packbits(hm, axis=-1, bitorder='little').flatten()
for ham in range(8):
h = np.histogram(a[np.where(hm == ham)]>>1, np.arange(17))
ax[1][x].bar(np.arange(16), h[0])
fig.tight_layout()
plt.show()
================================================
FILE: teletext/charset.py
================================================
# Name: Map from Teletext G0 character set to Unicode
# Date: 2018 April 20
# Author: Rebecca Bettencourt
g0 = {'default': {
0x20: chr(0x0020), # SPACE
0x21: chr(0x0021), # EXCLAMATION MARK
0x22: chr(0x0022), # QUOTATION MARK
0x23: chr(0x00A3), # POUND SIGN
0x24: chr(0x0024), # DOLLAR SIGN
0x25: chr(0x0025), # PERCENT SIGN
0x26: chr(0x0026), # AMPERSAND
0x27: chr(0x0027), # APOSTROPHE
0x28: chr(0x0028), # LEFT PARENTHESIS
0x29: chr(0x0029), # RIGHT PARENTHESIS
0x2A: chr(0x002A), # ASTERISK
0x2B: chr(0x002B), # PLUS SIGN
0x2C: chr(0x002C), # COMMA
0x2D: chr(0x002D), # HYPHEN-MINUS
0x2E: chr(0x002E), # FULL STOP
0x2F: chr(0x002F), # SOLIDUS
0x30: chr(0x0030), # DIGIT ZERO
0x31: chr(0x0031), # DIGIT ONE
0x32: chr(0x0032), # DIGIT TWO
0x33: chr(0x0033), # DIGIT THREE
0x34: chr(0x0034), # DIGIT FOUR
0x35: chr(0x0035), # DIGIT FIVE
0x36: chr(0x0036), # DIGIT SIX
0x37: chr(0x0037), # DIGIT SEVEN
0x38: chr(0x0038), # DIGIT EIGHT
0x39: chr(0x0039), # DIGIT NINE
0x3A: chr(0x003A), # COLON
0x3B: chr(0x003B), # SEMICOLON
0x3C: chr(0x003C), # LESS-THAN SIGN
0x3D: chr(0x003D), # EQUALS SIGN
0x3E: chr(0x003E), # GREATER-THAN SIGN
0x3F: chr(0x003F), # QUESTION MARK
0x40: chr(0x0040), # COMMERCIAL AT
0x41: chr(0x0041), # LATIN CAPITAL LETTER A
0x42: chr(0x0042), # LATIN CAPITAL LETTER B
0x43: chr(0x0043), # LATIN CAPITAL LETTER C
0x44: chr(0x0044), # LATIN CAPITAL LETTER D
0x45: chr(0x0045), # LATIN CAPITAL LETTER E
0x46: chr(0x0046), # LATIN CAPITAL LETTER F
0x47: chr(0x0047), # LATIN CAPITAL LETTER G
0x48: chr(0x0048), # LATIN CAPITAL LETTER H
0x49: chr(0x0049), # LATIN CAPITAL LETTER I
0x4A: chr(0x004A), # LATIN CAPITAL LETTER J
0x4B: chr(0x004B), # LATIN CAPITAL LETTER K
0x4C: chr(0x004C), # LATIN CAPITAL LETTER L
0x4D: chr(0x004D), # LATIN CAPITAL LETTER M
0x4E: chr(0x004E), # LATIN CAPITAL LETTER N
0x4F: chr(0x004F), # LATIN CAPITAL LETTER O
0x50: chr(0x0050), # LATIN CAPITAL LETTER P
0x51: chr(0x0051), # LATIN CAPITAL LETTER Q
0x52: chr(0x0052), # LATIN CAPITAL LETTER R
0x53: chr(0x0053), # LATIN CAPITAL LETTER S
0x54: chr(0x0054), # LATIN CAPITAL LETTER T
0x55: chr(0x0055), # LATIN CAPITAL LETTER U
0x56: chr(0x0056), # LATIN CAPITAL LETTER V
0x57: chr(0x0057), # LATIN CAPITAL LETTER W
0x58: chr(0x0058), # LATIN CAPITAL LETTER X
0x59: chr(0x0059), # LATIN CAPITAL LETTER Y
0x5A: chr(0x005A), # LATIN CAPITAL LETTER Z
0x5B: chr(0x2190), # LEFTWARDS ARROW
0x5C: chr(0x00BD), # VULGAR FRACTION ONE HALF
0x5D: chr(0x2192), # RIGHTWARDS ARROW
0x5E: chr(0x2191), # UPWARDS ARROW
0x5F: chr(0x0023), # NUMBER SIGN
#0x60: chr(0x2500), # BOX DRAWINGS LIGHT HORIZONTAL
0x60: chr(0x2014), # EM DASH
0x61: chr(0x0061), # LATIN SMALL LETTER A
0x62: chr(0x0062), # LATIN SMALL LETTER B
0x63: chr(0x0063), # LATIN SMALL LETTER C
0x64: chr(0x0064), # LATIN SMALL LETTER D
0x65: chr(0x0065), # LATIN SMALL LETTER E
0x66: chr(0x0066), # LATIN SMALL LETTER F
0x67: chr(0x0067), # LATIN SMALL LETTER G
0x68: chr(0x0068), # LATIN SMALL LETTER H
0x69: chr(0x0069), # LATIN SMALL LETTER I
0x6A: chr(0x006A), # LATIN SMALL LETTER J
0x6B: chr(0x006B), # LATIN SMALL LETTER K
0x6C: chr(0x006C), # LATIN SMALL LETTER L
0x6D: chr(0x006D), # LATIN SMALL LETTER M
0x6E: chr(0x006E), # LATIN SMALL LETTER N
0x6F: chr(0x006F), # LATIN SMALL LETTER O
0x70: chr(0x0070), # LATIN SMALL LETTER P
0x71: chr(0x0071), # LATIN SMALL LETTER Q
0x72: chr(0x0072), # LATIN SMALL LETTER R
0x73: chr(0x0073), # LATIN SMALL LETTER S
0x74: chr(0x0074), # LATIN SMALL LETTER T
0x75: chr(0x0075), # LATIN SMALL LETTER U
0x76: chr(0x0076), # LATIN SMALL LETTER V
0x77: chr(0x0077), # LATIN SMALL LETTER W
0x78: chr(0x0078), # LATIN SMALL LETTER X
0x79: chr(0x0079), # LATIN SMALL LETTER Y
0x7A: chr(0x007A), # LATIN SMALL LETTER Z
0x7B: chr(0x00BC), # VULGAR FRACTION ONE QUARTER
0x7C: chr(0x2016), # DOUBLE VERTICAL LINE
0x7D: chr(0x00BE), # VULGAR FRACTION THREE QUARTERS
0x7E: chr(0x00F7), # DIVISION SIGN
0x7F: chr(0x25A0), # BLACK SQUARE
}, 'cyr': {
0x20: chr(0x0020), # SPACE
0x21: chr(0x0021), # EXCLAMATION MARK
0x22: chr(0x0022), # QUOTATION MARK
0x23: chr(0x00A3), # POUND SIGN
0x24: chr(0x0024), # DOLLAR SIGN
0x25: chr(0x0025), # PERCENT SIGN
0x26: chr(0x044B), # CYRILLIC SMALL LETTER YERU
0x27: chr(0x0027), # APOSTROPHE
0x28: chr(0x0028), # LEFT PARENTHESIS
0x29: chr(0x0029), # RIGHT PARENTHESIS
0x2A: chr(0x002A), # ASTERISK
0x2B: chr(0x002B), # PLUS SIGN
0x2C: chr(0x002C), # COMMA
0x2D: chr(0x002D), # HYPHEN-MINUS
0x2E: chr(0x002E), # FULL STOP
0x2F: chr(0x002F), # SOLIDUS
0x30: chr(0x0030), # DIGIT ZERO
0x31: chr(0x0031), # DIGIT ONE
0x32: chr(0x0032), # DIGIT TWO
0x33: chr(0x0033), # DIGIT THREE
0x34: chr(0x0034), # DIGIT FOUR
0x35: chr(0x0035), # DIGIT FIVE
0x36: chr(0x0036), # DIGIT SIX
0x37: chr(0x0037), # DIGIT SEVEN
0x38: chr(0x0038), # DIGIT EIGHT
0x39: chr(0x0039), # DIGIT NINE
0x3A: chr(0x003A), # COLON
0x3B: chr(0x003B), # SEMICOLON
0x3C: chr(0x003C), # LESS-THAN SIGN
0x3D: chr(0x003D), # EQUALS SIGN
0x3E: chr(0x003E), # GREATER-THAN SIGN
0x3F: chr(0x003F), # QUESTION MARK
0x40: chr(0x042E), # CYRILLIC CAPITAL LETTER YU
0x41: chr(0x0410), # CYRILLIC CAPITAL LETTER A
0x42: chr(0x0411), # CYRILLIC CAPITAL LETTER BE
0x43: chr(0x0426), # CYRILLIC CAPITAL LETTER TSE
0x44: chr(0x0414), # CYRILLIC CAPITAL LETTER DE
0x45: chr(0x0415), # CYRILLIC CAPITAL LETTER IE
0x46: chr(0x0424), # CYRILLIC CAPITAL LETTER EF
0x47: chr(0x0413), # CYRILLIC CAPITAL LETTER GHE
0x48: chr(0x0425), # CYRILLIC CAPITAL LETTER HA
0x49: chr(0x0418), # CYRILLIC CAPITAL LETTER I
0x4A: chr(0x0419), # CYRILLIC CAPITAL LETTER SHORT I
0x4B: chr(0x041A), # CYRILLIC CAPITAL LETTER KA
0x4C: chr(0x041B), # CYRILLIC CAPITAL LETTER EL
0x4D: chr(0x041C), # CYRILLIC CAPITAL LETTER EМ
0x4E: chr(0x041D), # CYRILLIC CAPITAL LETTER EN
0x4F: chr(0x041E), # CYRILLIC CAPITAL LETTER O
0x50: chr(0x041F), # CYRILLIC CAPITAL LETTER PE
0x51: chr(0x042F), # CYRILLIC CAPITAL LETTER YA
0x52: chr(0x0420), # CYRILLIC CAPITAL LETTER ER
0x53: chr(0x0421), # CYRILLIC CAPITAL LETTER ES
0x54: chr(0x0422), # CYRILLIC CAPITAL LETTER TE
0x55: chr(0x0423), # CYRILLIC CAPITAL LETTER U
0x56: chr(0x0416), # CYRILLIC CAPITAL LETTER ZHE
0x57: chr(0x0412), # CYRILLIC CAPITAL LETTER BE
0x58: chr(0x042C), # CYRILLIC CAPITAL LETTER SOFT SIGN
0x59: chr(0x042A), # CYRILLIC CAPITAL LETTER HARD SIGN
0x5A: chr(0x0417), # CYRILLIC CAPITAL LETTER ZE
0x5B: chr(0x0428), # CYRILLIC CAPITAL LETTER SHA
0x5C: chr(0x042D), # CYRILLIC CAPITAL LETTER E
0x5D: chr(0x0429), # CYRILLIC CAPITAL LETTER SHCHA
0x5E: chr(0x0427), # CYRILLIC CAPITAL LETTER CHA
0x5F: chr(0x042B), # CYRILLIC CAPITAL LETTER YERU
0x60: chr(0x044E), # CYRILLIC SMALL LETTER YU
0x61: chr(0x0430), # CYRILLIC SMALL LETTER A
0x62: chr(0x0431), # CYRILLIC SMALL LETTER BE
0x63: chr(0x0446), # CYRILLIC SMALL LETTER TSE
0x64: chr(0x0434), # CYRILLIC SMALL LETTER DE
0x65: chr(0x0435), # CYRILLIC SMALL LETTER IE
0x66: chr(0x0444), # CYRILLIC SMALL LETTER EF
0x67: chr(0x0433), # CYRILLIC SMALL LETTER GHE
0x68: chr(0x0445), # CYRILLIC SMALL LETTER HA
0x69: chr(0x0438), # CYRILLIC SMALL LETTER I
0x6A: chr(0x0439), # CYRILLIC SMALL LETTER SHORT I
0x6B: chr(0x043A), # CYRILLIC SMALL LETTER KA
0x6C: chr(0x043B), # CYRILLIC SMALL LETTER EL
0x6D: chr(0x043C), # CYRILLIC SMALL LETTER EM
0x6E: chr(0x043D), # CYRILLIC SMALL LETTER EN
0x6F: chr(0x043E), # CYRILLIC SMALL LETTER O
0x70: chr(0x043F), # CYRILLIC SMALL LETTER PE
0x71: chr(0x044F), # CYRILLIC SMALL LETTER YA
0x72: chr(0x0440), # CYRILLIC SMALL LETTER ER
0x73: chr(0x0441), # CYRILLIC SMALL LETTER ES
0x74: chr(0x0442), # CYRILLIC SMALL LETTER TE
0x75: chr(0x0443), # CYRILLIC SMALL LETTER U
0x76: chr(0x0436), # CYRILLIC SMALL LETTER ZHE
0x77: chr(0x0432), # CYRILLIC SMALL LETTER BE
0x78: chr(0x044C), # CYRILLIC SMALL LETTER SOFT SIGN
0x79: chr(0x044A), # CYRILLIC SMALL LETTER HARD SIGN
0x7A: chr(0x0437), # CYRILLIC SMALL LETTER ZE
0x7B: chr(0x0448), # CYRILLIC SMALL LETTER SHA
0x7C: chr(0x044D), # CYRILLIC SMALL LETTER E
0x7D: chr(0x0449), # CYRILLIC SMALL LETTER SHCHA
0x7E: chr(0x0447), # CYRILLIC SMALL LETTER CHE
0x7F: chr(0x25A0), # BLACK SQUARE
# Swedish national subset (replaces characters 0x40, 0x5B-0x5F, 0x60, 0x7B-0x7E)
}, 'swe': {
0x20: chr(0x0020), # SPACE
0x21: chr(0x0021), # EXCLAMATION MARK
0x22: chr(0x0022), # QUOTATION MARK
0x23: chr(0x00A3), # POUND SIGN
0x24: chr(0x0024), # DOLLAR SIGN
0x25: chr(0x0025), # PERCENT SIGN
0x26: chr(0x0026), # AMPERSAND
0x27: chr(0x0027), # APOSTROPHE
0x28: chr(0x0028), # LEFT PARENTHESIS
0x29: chr(0x0029), # RIGHT PARENTHESIS
0x2A: chr(0x002A), # ASTERISK
0x2B: chr(0x002B), # PLUS SIGN
0x2C: chr(0x002C), # COMMA
0x2D: chr(0x002D), # HYPHEN-MINUS
0x2E: chr(0x002E), # FULL STOP
0x2F: chr(0x002F), # SOLIDUS
0x30: chr(0x0030), # DIGIT ZERO
0x31: chr(0x0031), # DIGIT ONE
0x32: chr(0x0032), # DIGIT TWO
0x33: chr(0x0033), # DIGIT THREE
0x34: chr(0x0034), # DIGIT FOUR
0x35: chr(0x0035), # DIGIT FIVE
0x36: chr(0x0036), # DIGIT SIX
0x37: chr(0x0037), # DIGIT SEVEN
0x38: chr(0x0038), # DIGIT EIGHT
0x39: chr(0x0039), # DIGIT NINE
0x3A: chr(0x003A), # COLON
0x3B: chr(0x003B), # SEMICOLON
0x3C: chr(0x003C), # LESS-THAN SIGN
0x3D: chr(0x003D), # EQUALS SIGN
0x3E: chr(0x003E), # GREATER-THAN SIGN
0x3F: chr(0x003F), # QUESTION MARK
0x40: chr(0x00C9), # CAPITAL E-ACUTE
0x41: chr(0x0041), # LATIN CAPITAL LETTER A
0x42: chr(0x0042), # LATIN CAPITAL LETTER B
0x43: chr(0x0043), # LATIN CAPITAL LETTER C
0x44: chr(0x0044), # LATIN CAPITAL LETTER D
0x45: chr(0x0045), # LATIN CAPITAL LETTER E
0x46: chr(0x0046), # LATIN CAPITAL LETTER F
0x47: chr(0x0047), # LATIN CAPITAL LETTER G
0x48: chr(0x0048), # LATIN CAPITAL LETTER H
0x49: chr(0x0049), # LATIN CAPITAL LETTER I
0x4A: chr(0x004A), # LATIN CAPITAL LETTER J
0x4B: chr(0x004B), # LATIN CAPITAL LETTER K
0x4C: chr(0x004C), # LATIN CAPITAL LETTER L
0x4D: chr(0x004D), # LATIN CAPITAL LETTER M
0x4E: chr(0x004E), # LATIN CAPITAL LETTER N
0x4F: chr(0x004F), # LATIN CAPITAL LETTER O
0x50: chr(0x0050), # LATIN CAPITAL LETTER P
0x51: chr(0x0051), # LATIN CAPITAL LETTER Q
0x52: chr(0x0052), # LATIN CAPITAL LETTER R
0x53: chr(0x0053), # LATIN CAPITAL LETTER S
0x54: chr(0x0054), # LATIN CAPITAL LETTER T
0x55: chr(0x0055), # LATIN CAPITAL LETTER U
0x56: chr(0x0056), # LATIN CAPITAL LETTER V
0x57: chr(0x0057), # LATIN CAPITAL LETTER W
0x58: chr(0x0058), # LATIN CAPITAL LETTER X
0x59: chr(0x0059), # LATIN CAPITAL LETTER Y
0x5A: chr(0x005A), # LATIN CAPITAL LETTER Z
0x5B: chr(0x00C4), # LATIN CAPITAL A WITH DIAERESIS
0x5C: chr(0x00D6), # LATIN CAPITAL O WITH DIAERESIS
0x5D: chr(0x00C5), # LATIN CAPITAL A WITH OVERRING
0x5E: chr(0x00DC), # LATIN CAPITAL U WITH DIAERESIS
0x5F: chr(0x005F), # UNDERSCORE
#0x60: chr(0x2500), # BOX DRAWINGS LIGHT HORIZONTAL
0x60: chr(0x00E9), # LOWER-CASE E-ACUTE
0x61: chr(0x0061), # LATIN SMALL LETTER A
0x62: chr(0x0062), # LATIN SMALL LETTER B
0x63: chr(0x0063), # LATIN SMALL LETTER C
0x64: chr(0x0064), # LATIN SMALL LETTER D
0x65: chr(0x0065), # LATIN SMALL LETTER E
0x66: chr(0x0066), # LATIN SMALL LETTER F
0x67: chr(0x0067), # LATIN SMALL LETTER G
0x68: chr(0x0068), # LATIN SMALL LETTER H
0x69: chr(0x0069), # LATIN SMALL LETTER I
0x6A: chr(0x006A), # LATIN SMALL LETTER J
0x6B: chr(0x006B), # LATIN SMALL LETTER K
0x6C: chr(0x006C), # LATIN SMALL LETTER L
0x6D: chr(0x006D), # LATIN SMALL LETTER M
0x6E: chr(0x006E), # LATIN SMALL LETTER N
0x6F: chr(0x006F), # LATIN SMALL LETTER O
0x70: chr(0x0070), # LATIN SMALL LETTER P
0x71: chr(0x0071), # LATIN SMALL LETTER Q
0x72: chr(0x0072), # LATIN SMALL LETTER R
0x73: chr(0x0073), # LATIN SMALL LETTER S
0x74: chr(0x0074), # LATIN SMALL LETTER T
0x75: chr(0x0075), # LATIN SMALL LETTER U
0x76: chr(0x0076), # LATIN SMALL LETTER V
0x77: chr(0x0077), # LATIN SMALL LETTER W
0x78: chr(0x0078), # LATIN SMALL LETTER X
0x79: chr(0x0079), # LATIN SMALL LETTER Y
0x7A: chr(0x007A), # LATIN SMALL LETTER Z
0x7B: chr(0x00E4), # LATIN SMALL A WITH DIAERESIS
0x7C: chr(0x00F6), # LATIN SMALL O WITH DIAERESIS
0x7D: chr(0x00E5), # LATIN SMALL A WITH OVERRING
0x7E: chr(0x00FC), # LATIN SMALL U WITH DIAERESIS
0x7F: chr(0x25A0), # BLACK SQUARE
}}
# Name: Map from Teletext G1 character set to Unicode
# Date: 2018 April 20
# Author: Rebecca Bettencourt
g1 = {
0x20: chr(0x00A0), # NO-BREAK SPACE; unification of EMPTY BLOCK SEXTANT
0x21: chr(0x1FB00), # BLOCK SEXTANT-1
0x22: chr(0x1FB01), # BLOCK SEXTANT-2
0x23: chr(0x1FB02), # BLOCK SEXTANT-12
0x24: chr(0x1FB03), # BLOCK SEXTANT-3
0x25: chr(0x1FB04), # BLOCK SEXTANT-13
0x26: chr(0x1FB05), # BLOCK SEXTANT-23
0x27: chr(0x1FB06), # BLOCK SEXTANT-123
0x28: chr(0x1FB07), # BLOCK SEXTANT-4
0x29: chr(0x1FB08), # BLOCK SEXTANT-14
0x2A: chr(0x1FB09), # BLOCK SEXTANT-24
0x2B: chr(0x1FB0A), # BLOCK SEXTANT-124
0x2C: chr(0x1FB0B), # BLOCK SEXTANT-34
0x2D: chr(0x1FB0C), # BLOCK SEXTANT-134
0x2E: chr(0x1FB0D), # BLOCK SEXTANT-234
0x2F: chr(0x1FB0E), # BLOCK SEXTANT-1234
0x30: chr(0x1FB0F), # BLOCK SEXTANT-5
0x31: chr(0x1FB10), # BLOCK SEXTANT-15
0x32: chr(0x1FB11), # BLOCK SEXTANT-25
0x33: chr(0x1FB12), # BLOCK SEXTANT-125
0x34: chr(0x1FB13), # BLOCK SEXTANT-35
0x35: chr(0x258C), # LEFT HALF BLOCK; unification of BLOCK SEXTANT-135
0x36: chr(0x1FB14), # BLOCK SEXTANT-235
0x37: chr(0x1FB15), # BLOCK SEXTANT-1235
0x38: chr(0x1FB16), # BLOCK SEXTANT-45
0x39: chr(0x1FB17), # BLOCK SEXTANT-145
0x3A: chr(0x1FB18), # BLOCK SEXTANT-245
0x3B: chr(0x1FB19), # BLOCK SEXTANT-1245
0x3C: chr(0x1FB1A), # BLOCK SEXTANT-345
0x3D: chr(0x1FB1B), # BLOCK SEXTANT-1345
0x3E: chr(0x1FB1C), # BLOCK SEXTANT-2345
0x3F: chr(0x1FB1D), # BLOCK SEXTANT-12345
0x40: chr(0x0040), # COMMERCIAL AT
0x41: chr(0x0041), # LATIN CAPITAL LETTER A
0x42: chr(0x0042), # LATIN CAPITAL LETTER B
0x43: chr(0x0043), # LATIN CAPITAL LETTER C
0x44: chr(0x0044), # LATIN CAPITAL LETTER D
0x45: chr(0x0045), # LATIN CAPITAL LETTER E
0x46: chr(0x0046), # LATIN CAPITAL LETTER F
0x47: chr(0x0047), # LATIN CAPITAL LETTER G
0x48: chr(0x0048), # LATIN CAPITAL LETTER H
0x49: chr(0x0049), # LATIN CAPITAL LETTER I
0x4A: chr(0x004A), # LATIN CAPITAL LETTER J
0x4B: chr(0x004B), # LATIN CAPITAL LETTER K
0x4C: chr(0x004C), # LATIN CAPITAL LETTER L
0x4D: chr(0x004D), # LATIN CAPITAL LETTER M
0x4E: chr(0x004E), # LATIN CAPITAL LETTER N
0x4F: chr(0x004F), # LATIN CAPITAL LETTER O
0x50: chr(0x0050), # LATIN CAPITAL LETTER P
0x51: chr(0x0051), # LATIN CAPITAL LETTER Q
0x52: chr(0x0052), # LATIN CAPITAL LETTER R
0x53: chr(0x0053), # LATIN CAPITAL LETTER S
0x54: chr(0x0054), # LATIN CAPITAL LETTER T
0x55: chr(0x0055), # LATIN CAPITAL LETTER U
0x56: chr(0x0056), # LATIN CAPITAL LETTER V
0x57: chr(0x0057), # LATIN CAPITAL LETTER W
0x58: chr(0x0058), # LATIN CAPITAL LETTER X
0x59: chr(0x0059), # LATIN CAPITAL LETTER Y
0x5A: chr(0x005A), # LATIN CAPITAL LETTER Z
0x5B: chr(0x2190), # LEFTWARDS ARROW
0x5C: chr(0x00BD), # VULGAR FRACTION ONE HALF
0x5D: chr(0x2192), # RIGHTWARDS ARROW
0x5E: chr(0x2191), # UPWARDS ARROW
0x5F: chr(0x0023), # NUMBER SIGN
0x60: chr(0x1FB1E), # BLOCK SEXTANT-6
0x61: chr(0x1FB1F), # BLOCK SEXTANT-16
0x62: chr(0x1FB20), # BLOCK SEXTANT-26
0x63: chr(0x1FB21), # BLOCK SEXTANT-126
0x64: chr(0x1FB22), # BLOCK SEXTANT-36
0x65: chr(0x1FB23), # BLOCK SEXTANT-136
0x66: chr(0x1FB24), # BLOCK SEXTANT-236
0x67: chr(0x1FB25), # BLOCK SEXTANT-1236
0x68: chr(0x1FB26), # BLOCK SEXTANT-46
0x69: chr(0x1FB27), # BLOCK SEXTANT-146
0x6A: chr(0x2590), # RIGHT HALF BLOCK; unification of BLOCK SEXTANT-246
0x6B: chr(0x1FB28), # BLOCK SEXTANT-1246
0x6C: chr(0x1FB29), # BLOCK SEXTANT-346
0x6D: chr(0x1FB2A), # BLOCK SEXTANT-1346
0x6E: chr(0x1FB2B), # BLOCK SEXTANT-2346
0x6F: chr(0x1FB2C), # BLOCK SEXTANT-12346
0x70: chr(0x1FB2D), # BLOCK SEXTANT-56
0x71: chr(0x1FB2E), # BLOCK SEXTANT-156
0x72: chr(0x1FB2F), # BLOCK SEXTANT-256
0x73: chr(0x1FB30), # BLOCK SEXTANT-1256
0x74: chr(0x1FB31), # BLOCK SEXTANT-356
0x75: chr(0x1FB32), # BLOCK SEXTANT-1356
0x76: chr(0x1FB33), # BLOCK SEXTANT-2356
0x77: chr(0x1FB34), # BLOCK SEXTANT-12356
0x78: chr(0x1FB35), # BLOCK SEXTANT-456
0x79: chr(0x1FB36), # BLOCK SEXTANT-1456
0x7A: chr(0x1FB37), # BLOCK SEXTANT-2456
0x7B: chr(0x1FB38), # BLOCK SEXTANT-12456
0x7C: chr(0x1FB39), # BLOCK SEXTANT-3456
0x7D: chr(0x1FB3A), # BLOCK SEXTANT-13456
0x7E: chr(0x1FB3B), # BLOCK SEXTANT-23456
0x7F: chr(0x2588), # FULL BLOCK; unification of BLOCK SEXTANT-123456
}
# Name: Map from Teletext G2 character set to Unicode
# Date: 2018 April 20
# Author: Rebecca Bettencourt
g2 = {
0x20: chr(0x0020), # SPACE
0x21: chr(0x00A1), # INVERTED EXCLAMATION MARK
0x22: chr(0x00A2), # CENT SIGN
0x23: chr(0x00A3), # POUND SIGN
0x24: chr(0x0024), # DOLLAR SIGN
0x25: chr(0x00A5), # YEN SIGN
0x26: chr(0x0023), # NUMBER SIGN
0x27: chr(0x00A7), # SECTION SIGN
0x28: chr(0x00A4), # CURRENCY SIGN
0x29: chr(0x2018), # LEFT SINGLE QUOTATION MARK
0x2A: chr(0x201C), # LEFT DOUBLE QUOTATION MARK
0x2B: chr(0x00AB), # LEFT-POINTING DOUBLE ANGLE QUOTATION MARK
0x2C: chr(0x2190), # LEFTWARDS ARROW
0x2D: chr(0x2191), # UPWARDS ARROW
0x2E: chr(0x2192), # RIGHTWARDS ARROW
0x2F: chr(0x2193), # DOWNWARDS ARROW
0x30: chr(0x00B0), # DEGREE SIGN
0x31: chr(0x00B1), # PLUS-MINUS SIGN
0x32: chr(0x00B2), # SUPERSCRIPT TWO
0x33: chr(0x00B3), # SUPERSCRIPT THREE
0x34: chr(0x00D7), # MULTIPLICATION SIGN
0x35: chr(0x00B5), # MICRO SIGN
0x36: chr(0x00B6), # PILCROW SIGN
0x37: chr(0x00B7), # MIDDLE DOT
0x38: chr(0x00F7), # DIVISION SIGN
0x39: chr(0x2019), # RIGHT SINGLE QUOTATION MARK
0x3A: chr(0x201D), # RIGHT DOUBLE QUOTATION MARK
0x3B: chr(0x00BB), # RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK
0x3C: chr(0x00BC), # VULGAR FRACTION ONE QUARTER
0x3D: chr(0x00BD), # VULGAR FRACTION ONE HALF
0x3E: chr(0x00BE), # VULGAR FRACTION THREE QUARTERS
0x3F: chr(0x00BF), # INVERTED QUESTION MARK
0x40: chr(0x00A0), # NO-BREAK SPACE
0x41: chr(0x02CB), # MODIFIER LETTER GRAVE ACCENT
0x42: chr(0x02CA), # MODIFIER LETTER ACUTE ACCENT
0x43: chr(0x02C6), # MODIFIER LETTER CIRCUMFLEX ACCENT
0x44: chr(0x02DC), # SMALL TILDE
0x45: chr(0x02C9), # MODIFIER LETTER MACRON
0x46: chr(0x02D8), # BREVE
0x47: chr(0x02D9), # DOT ABOVE
0x48: chr(0x00A8), # DIAERESIS
0x49: chr(0x02CC), # MODIFIER LETTER LOW VERTICAL LINE
0x4A: chr(0x02DA), # RING ABOVE
0x4B: chr(0x00B8), # CEDILLA
0x4C: chr(0x005F), # LOW LINE
0x4D: chr(0x02DD), # DOUBLE ACUTE ACCENT
0x4E: chr(0x02DB), # OGONEK
0x4F: chr(0x02C7), # CARON
0x50: chr(0x2500), # BOX DRAWINGS LIGHT HORIZONTAL
0x51: chr(0x00B9), # SUPERSCRIPT ONE
0x52: chr(0x00AE), # REGISTERED SIGN
0x53: chr(0x00A9), # COPYRIGHT SIGN
0x54: chr(0x2122), # TRADE MARK SIGN
0x55: chr(0x266A), # EIGHTH NOTE
0x56: chr(0x20A0), # EURO-CURRENCY SIGN
0x57: chr(0x2030), # PER MILLE SIGN
0x58: chr(0x03B1), # GREEK SMALL LETTER ALPHA
0x5C: chr(0x215B), # VULGAR FRACTION ONE EIGHTH
0x5D: chr(0x215C), # VULGAR FRACTION THREE EIGHTHS
0x5E: chr(0x215D), # VULGAR FRACTION FIVE EIGHTHS
0x5F: chr(0x215E), # VULGAR FRACTION SEVEN EIGHTHS
0x60: chr(0x03A9), # GREEK CAPITAL LETTER OMEGA
0x61: chr(0x00C6), # LATIN CAPITAL LETTER AE
0x62: chr(0x00D0), # LATIN CAPITAL LETTER ETH
0x63: chr(0x00AA), # FEMININE ORDINAL INDICATOR
0x64: chr(0x0126), # LATIN CAPITAL LETTER H WITH STROKE
0x66: chr(0x0132), # LATIN CAPITAL LIGATURE IJ
0x67: chr(0x013F), # LATIN CAPITAL LETTER L WITH MIDDLE DOT
0x68: chr(0x0141), # LATIN CAPITAL LETTER L WITH STROKE
0x69: chr(0x00D8), # LATIN CAPITAL LETTER O WITH STROKE
0x6A: chr(0x0152), # LATIN CAPITAL LIGATURE OE
0x6B: chr(0x00BA), # MASCULINE ORDINAL INDICATOR
0x6C: chr(0x00DE), # LATIN CAPITAL LETTER THORN
0x6D: chr(0x0166), # LATIN CAPITAL LETTER T WITH STROKE
0x6E: chr(0x014A), # LATIN CAPITAL LETTER ENG
0x6F: chr(0x0149), # LATIN SMALL LETTER N PRECEDED BY APOSTROPHE
0x70: chr(0x0138), # LATIN SMALL LETTER KRA
0x71: chr(0x00E6), # LATIN SMALL LETTER AE
0x72: chr(0x0111), # LATIN SMALL LETTER D WITH STROKE
0x73: chr(0x00F0), # LATIN SMALL LETTER ETH
0x74: chr(0x0127), # LATIN SMALL LETTER H WITH STROKE
0x75: chr(0x0131), # LATIN SMALL LETTER DOTLESS I
0x76: chr(0x0133), # LATIN SMALL LIGATURE IJ
0x77: chr(0x0140), # LATIN SMALL LETTER L WITH MIDDLE DOT
0x78: chr(0x0142), # LATIN SMALL LETTER L WITH STROKE
0x79: chr(0x00F8), # LATIN SMALL LETTER O WITH STROKE
0x7A: chr(0x0153), # LATIN SMALL LIGATURE OE
0x7B: chr(0x00DF), # LATIN SMALL LETTER SHARP S
0x7C: chr(0x00FE), # LATIN SMALL LETTER THORN
0x7D: chr(0x0167), # LATIN SMALL LETTER T WITH STROKE
0x7E: chr(0x014B), # LATIN SMALL LETTER ENG
0x7F: chr(0x25A0), # BLACK SQUARE
}
# Name: Map from Teletext G3 character set to Unicode
# Date: 2018 April 20
# Author: Rebecca Bettencourt
g3 = {
0x20: chr(0x1FB3C), # LOWER LEFT BLOCK DIAGONAL LOWER MIDDLE LEFT TO LOWER CENTRE
0x21: chr(0x1FB3D), # LOWER LEFT BLOCK DIAGONAL LOWER MIDDLE LEFT TO LOWER RIGHT
0x22: chr(0x1FB3E), # LOWER LEFT BLOCK DIAGONAL UPPER MIDDLE LEFT TO LOWER CENTRE
0x23: chr(0x1FB3F), # LOWER LEFT BLOCK DIAGONAL UPPER MIDDLE LEFT TO LOWER RIGHT
0x24: chr(0x1FB40), # LOWER LEFT BLOCK DIAGONAL UPPER LEFT TO LOWER CENTRE
0x25: chr(0x25E3), # BLACK LOWER LEFT TRIANGLE
0x26: chr(0x1FB41), # LOWER RIGHT BLOCK DIAGONAL UPPER MIDDLE LEFT TO UPPER CENTRE
0x27: chr(0x1FB42), # LOWER RIGHT BLOCK DIAGONAL UPPER MIDDLE LEFT TO UPPER RIGHT
0x28: chr(0x1FB43), # LOWER RIGHT BLOCK DIAGONAL LOWER MIDDLE LEFT TO UPPER CENTRE
0x29: chr(0x1FB44), # LOWER RIGHT BLOCK DIAGONAL LOWER MIDDLE LEFT TO UPPER RIGHT
0x2A: chr(0x1FB45), # LOWER RIGHT BLOCK DIAGONAL LOWER LEFT TO UPPER CENTRE
0x2B: chr(0x1FB46), # LOWER RIGHT BLOCK DIAGONAL LOWER MIDDLE LEFT TO UPPER MIDDLE RIGHT
0x2C: chr(0x1FB68), # UPPER AND RIGHT AND LOWER TRIANGULAR THREE QUARTERS BLOCK
0x2D: chr(0x1FB69), # LEFT AND LOWER AND RIGHT TRIANGULAR THREE QUARTERS BLOCK
0x2E: chr(0x1FB70), # VERTICAL ONE EIGHTH BLOCK-2
0x2F: chr(0x2592), # MEDIUM SHADE
0x30: chr(0x1FB47), # LOWER RIGHT BLOCK DIAGONAL LOWER CENTRE TO LOWER MIDDLE RIGHT
0x31: chr(0x1FB48), # LOWER RIGHT BLOCK DIAGONAL LOWER LEFT TO LOWER MIDDLE RIGHT
0x32: chr(0x1FB49), # LOWER RIGHT BLOCK DIAGONAL LOWER CENTRE TO UPPER MIDDLE RIGHT
0x33: chr(0x1FB4A), # LOWER RIGHT BLOCK DIAGONAL LOWER LEFT TO UPPER MIDDLE RIGHT
0x34: chr(0x1FB4B), # LOWER RIGHT BLOCK DIAGONAL LOWER CENTRE TO UPPER RIGHT
0x35: chr(0x25E2), # BLACK LOWER RIGHT TRIANGLE
0x36: chr(0x1FB4C), # LOWER LEFT BLOCK DIAGONAL UPPER CENTRE TO UPPER MIDDLE RIGHT
0x37: chr(0x1FB4D), # LOWER LEFT BLOCK DIAGONAL UPPER LEFT TO UPPER MIDDLE RIGHT
0x38: chr(0x1FB4E), # LOWER LEFT BLOCK DIAGONAL UPPER CENTRE TO LOWER MIDDLE RIGHT
0x39: chr(0x1FB4F), # LOWER LEFT BLOCK DIAGONAL UPPER LEFT TO LOWER MIDDLE RIGHT
0x3A: chr(0x1FB50), # LOWER LEFT BLOCK DIAGONAL UPPER CENTRE TO LOWER RIGHT
0x3B: chr(0x1FB51), # LOWER LEFT BLOCK DIAGONAL UPPER MIDDLE LEFT TO LOWER MIDDLE RIGHT
0x3C: chr(0x1FB6A), # UPPER AND LEFT AND LOWER TRIANGULAR THREE QUARTERS BLOCK
0x3D: chr(0x1FB6B), # LEFT AND UPPER AND RIGHT TRIANGULAR THREE QUARTERS BLOCK
0x3E: chr(0x1FB75), # VERTICAL ONE EIGHTH BLOCK-7
0x3F: chr(0x2588), # FULL BLOCK
0x40: chr(0x2537), # BOX DRAWINGS UP LIGHT AND HORIZONTAL HEAVY
0x41: chr(0x252F), # BOX DRAWINGS DOWN LIGHT AND HORIZONTAL HEAVY
0x42: chr(0x251D), # BOX DRAWINGS VERTICAL LIGHT AND RIGHT HEAVY
0x43: chr(0x2525), # BOX DRAWINGS VERTICAL LIGHT AND LEFT HEAVY
0x44: chr(0x1FBA4), # BOX DRAWINGS LIGHT DIAGONAL UPPER CENTRE TO MIDDLE LEFT TO LOWER CENTRE
0x45: chr(0x1FBA5), # BOX DRAWINGS LIGHT DIAGONAL UPPER CENTRE TO MIDDLE RIGHT TO LOWER CENTRE
0x46: chr(0x1FBA6), # BOX DRAWINGS LIGHT DIAGONAL MIDDLE LEFT TO LOWER CENTRE TO MIDDLE RIGHT
0x47: chr(0x1FBA7), # BOX DRAWINGS LIGHT DIAGONAL MIDDLE LEFT TO UPPER CENTRE TO MIDDLE RIGHT
0x48: chr(0x1FBA0), # BOX DRAWINGS LIGHT DIAGONAL UPPER CENTRE TO MIDDLE LEFT
0x49: chr(0x1FBA1), # BOX DRAWINGS LIGHT DIAGONAL UPPER CENTRE TO MIDDLE RIGHT
0x4A: chr(0x1FBA2), # BOX DRAWINGS LIGHT DIAGONAL MIDDLE LEFT TO LOWER CENTRE
0x4B: chr(0x1FBA3), # BOX DRAWINGS LIGHT DIAGONAL MIDDLE RIGHT TO LOWER CENTRE
0x4C: chr(0x253F), # BOX DRAWINGS VERTICAL LIGHT AND HORIZONTAL HEAVY
0x4D: chr(0x2022), # BULLET
0x4E: chr(0x25CF), # BLACK CIRCLE
0x4F: chr(0x25CB), # WHITE CIRCLE
0x50: chr(0x2502), # BOX DRAWINGS LIGHT VERTICAL
0x51: chr(0x2500), # BOX DRAWINGS LIGHT HORIZONTAL
0x52: chr(0x250C), # BOX DRAWINGS LIGHT DOWN AND RIGHT
0x53: chr(0x2510), # BOX DRAWINGS LIGHT DOWN AND LEFT
0x54: chr(0x2514), # BOX DRAWINGS LIGHT UP AND RIGHT
0x55: chr(0x2518), # BOX DRAWINGS LIGHT UP AND LEFT
0x56: chr(0x251C), # BOX DRAWINGS LIGHT VERTICAL AND RIGHT
0x57: chr(0x2524), # BOX DRAWINGS LIGHT VERTICAL AND LEFT
0x58: chr(0x252C), # BOX DRAWINGS LIGHT DOWN AND HORIZONTAL
0x59: chr(0x2534), # BOX DRAWINGS LIGHT UP AND HORIZONTAL
0x5A: chr(0x253C), # BOX DRAWINGS LIGHT VERTICAL AND HORIZONTAL
0x5B: chr(0x2B62), # RIGHTWARDS TRIANGLE-HEADED ARROW
0x5C: chr(0x2B60), # LEFTWARDS TRIANGLE-HEADED ARROW
0x5D: chr(0x2B61), # UPWARDS TRIANGLE-HEADED ARROW
0x5E: chr(0x2B63), # DOWNWARDS TRIANGLE-HEADED ARROW
0x5F: chr(0x00A0), # NO-BREAK SPACE
0x60: chr(0x1FB52), # UPPER RIGHT BLOCK DIAGONAL LOWER MIDDLE LEFT TO LOWER CENTRE
0x61: chr(0x1FB53), # UPPER RIGHT BLOCK DIAGONAL LOWER MIDDLE LEFT TO LOWER RIGHT
0x62: chr(0x1FB54), # UPPER RIGHT BLOCK DIAGONAL UPPER MIDDLE LEFT TO LOWER CENTRE
0x63: chr(0x1FB55), # UPPER RIGHT BLOCK DIAGONAL UPPER MIDDLE LEFT TO LOWER RIGHT
0x64: chr(0x1FB56), # UPPER RIGHT BLOCK DIAGONAL UPPER LEFT TO LOWER CENTRE
0x65: chr(0x25E5), # BLACK UPPER RIGHT TRIANGLE
0x66: chr(0x1FB57), # UPPER LEFT BLOCK DIAGONAL UPPER MIDDLE LEFT TO UPPER CENTRE
0x67: chr(0x1FB58), # UPPER LEFT BLOCK DIAGONAL UPPER MIDDLE LEFT TO UPPER RIGHT
0x68: chr(0x1FB59), # UPPER LEFT BLOCK DIAGONAL LOWER MIDDLE LEFT TO UPPER CENTRE
0x69: chr(0x1FB5A), # UPPER LEFT BLOCK DIAGONAL LOWER MIDDLE LEFT TO UPPER RIGHT
0x6A: chr(0x1FB5B), # UPPER LEFT BLOCK DIAGONAL LOWER LEFT TO UPPER CENTRE
0x6B: chr(0x1FB5C), # UPPER LEFT BLOCK DIAGONAL LOWER MIDDLE LEFT TO UPPER MIDDLE RIGHT
0x6C: chr(0x1FB6C), # LEFT TRIANGULAR ONE QUARTER BLOCK
0x6D: chr(0x1FB6D), # UPPER TRIANGULAR ONE QUARTER BLOCK
0x70: chr(0x1FB5D), # UPPER LEFT BLOCK DIAGONAL LOWER CENTRE TO LOWER MIDDLE RIGHT
0x71: chr(0x1FB5E), # UPPER LEFT BLOCK DIAGONAL LOWER LEFT TO LOWER MIDDLE RIGHT
0x72: chr(0x1FB5F), # UPPER LEFT BLOCK DIAGONAL LOWER CENTRE TO UPPER MIDDLE RIGHT
0x73: chr(0x1FB60), # UPPER LEFT BLOCK DIAGONAL LOWER LEFT TO UPPER MIDDLE RIGHT
0x74: chr(0x1FB61), # UPPER LEFT BLOCK DIAGONAL LOWER CENTRE TO UPPER RIGHT
0x75: chr(0x25E4), # BLACK UPPER LEFT TRIANGLE
0x76: chr(0x1FB62), # UPPER RIGHT BLOCK DIAGONAL UPPER CENTRE TO UPPER MIDDLE RIGHT
0x77: chr(0x1FB63), # UPPER RIGHT BLOCK DIAGONAL UPPER LEFT TO UPPER MIDDLE RIGHT
0x78: chr(0x1FB64), # UPPER RIGHT BLOCK DIAGONAL UPPER CENTRE TO LOWER MIDDLE RIGHT
0x79: chr(0x1FB65), # UPPER RIGHT BLOCK DIAGONAL UPPER LEFT TO LOWER MIDDLE RIGHT
0x7A: chr(0x1FB66), # UPPER RIGHT BLOCK DIAGONAL UPPER CENTRE TO LOWER RIGHT
0x7B: chr(0x1FB67), # UPPER RIGHT BLOCK DIAGONAL UPPER MIDDLE LEFT TO LOWER MIDDLE RIGHT
0x7C: chr(0x1FB6E), # RIGHT TRIANGULAR ONE QUARTER BLOCK
0x7D: chr(0x1FB6F), # LOWER TRIANGULAR ONE QUARTER BLOCK
}
================================================
FILE: teletext/cli/__init__.py
================================================
================================================
FILE: teletext/cli/celp.py
================================================
import click
from teletext.cli.clihelpers import packetreader
from teletext.celp import CELPDecoder
@click.group()
def celp():
"""Tools for analysing CELP audio packets."""
pass
@celp.command()
@click.option('-f', '--frame', type=int, default=None, help='Frame selection.')
@click.option('-o', '--output', type=click.File('wb'), help='Write audio to WAV file.')
@click.option('-l', '--lsf-lut', type=click.Choice(CELPDecoder.lsf_vector_quantizers.keys()), default='suddle', help='LSF vector look-up table.')
@click.option('-g', '--gain-lut', type=click.Choice(CELPDecoder.vec_gain_quantizers.keys()), default='audetel', help='LSF vector look-up table.')
@packetreader(filtered='data', mag_hist=None, row_hist=None, err_hist=None, pass_progress=True)
def play(progress, frame, output, lsf_lut, gain_lut, packets):
"""Play data from CELP packets. Warning: Will make a horrible noise."""
dec = CELPDecoder(lsf_lut=lsf_lut, vec_gain_lut=gain_lut)
progress.postfix.append(dec.stats())
if output is not None:
dec.convert(output, packets, frame=frame)
else:
dec.play(packets, frame=frame)
@celp.command()
@packetreader(filtered='data')
def plot(packets):
"""Plot data from CELP packets. Experimental code."""
CELPDecoder.plot(packets)
================================================
FILE: teletext/cli/clihelpers.py
================================================
import cProfile
import os
import stat
from functools import wraps
import click
from tqdm import tqdm
from teletext import pipeline
from teletext.packet import Packet
from teletext.stats import StatsList, MagHistogram, RowHistogram, ErrorHistogram
from teletext.file import FileChunker
from teletext.vbi.config import Config
try:
import plop.collector as plop
except ImportError:
plop = None
class BasedIntType(click.ParamType):
name = "integer"
def convert(self, value, param, ctx):
if isinstance(value, int):
return value
try:
if value[:2].lower() == "0x":
return int(value[2:], 16)
return int(value, 10)
except ValueError:
self.fail(f"{value!r} is not a valid integer", param, ctx)
BasedInt = BasedIntType()
def dcnparams(f):
return click.option('-d', '--dcn', 'dcn', type=int, required=True, help='Data channel to read from.')(f)
def filterparams(enabled=True):
def fp(f):
if enabled:
for d in [
click.option('-m', '--mag', 'mags', type=int, multiple=True, default=range(9), help='Limit output to specific magazines. Can be specified multiple times.'),
click.option('-r', '--row', 'rows', type=int, multiple=True, default=range(32), help='Limit output to specific rows. Can be specified multiple times.'),
][::-1]:
f = d(f)
return f
return fp
def progressparams(progress=True, mag_hist=False, row_hist=False, err_hist=False):
def p(f):
if err_hist is not None:
f = click.option('--err-hist/--no-err-hist', default=err_hist, help='Display error distribution.')(f)
if row_hist is not None:
f = click.option('--row-hist/--no-row-hist', default=row_hist, help='Display row histogram.')(f)
if mag_hist is not None:
f = click.option('--mag-hist/--no-mag-hist', default=mag_hist, help='Display magazine histogram.')(f)
if progress is not None:
f = click.option('--progress/--no-progress', default=progress, help='Display progress bar.')(f)
return f
return p
def carduser(extended=False):
def c(f):
if extended:
for d in [
click.option('--sample-rate', type=float, default=None, help='Override capture card sample rate (Hz).'),
click.option('--sample-rate-adjust', type=float, default=0, help='Adjustment to default capture card sample rate (Hz).'),
click.option('--extra-roll', type=int, default=0, help='Shift line by N samples after locking to the packet.'),
click.option('--line-start-range', type=(int, int), default=(None, None), help='Override capture card line start offset.'),
][::-1]:
f = d(f)
@click.option('-c', '--card', type=click.Choice(list(Config.cards.keys())), default='bt8x8', help='Capture device type. Default: bt8x8.')
@click.option('--line-length', type=int, default=None, help='Override capture card samples per line.')
@wraps(f)
def wrapper(card, line_length=None, sample_rate=None, sample_rate_adjust=0, line_start_range=None, extra_roll=0, *args, **kwargs):
if line_start_range == (None, None):
line_start_range = None
config = Config(card=card, line_length=line_length, sample_rate=sample_rate, sample_rate_adjust=sample_rate_adjust, line_start_range=line_start_range, extra_roll=extra_roll)
return f(config=config, *args,**kwargs)
return wrapper
return c
def chunkreader(loop=False, dup_stdin=False):
def cr(f):
@click.argument('input', type=click.File('rb'), default='-')
@click.option('--start', type=int, default=0, help='Start at the Nth line of the input file.')
@click.option('--stop', type=int, default=None, help='Stop before the Nth line of the input file.')
@click.option('--step', type=int, default=1, help='Process every Nth line from the input file.')
@click.option('--limit', type=int, default=None, help='Stop after processing N lines from the input file.')
@wraps(f)
def wrapper(input, start, stop, step, limit, *args, **kwargs):
if input.isatty():
raise click.UsageError('No input file and stdin is a tty - exiting.', )
if 'progress' in kwargs and kwargs['progress'] is None:
if hasattr(input, 'fileno') and stat.S_ISFIFO(os.fstat(input.fileno()).st_mode):
kwargs['progress'] = False
chunker = lambda size, flines=16, frange=range(0, 16): FileChunker(input, size, start, stop, step, limit, flines, frange, loop=loop, dup_stdin=dup_stdin)
return f(chunker=chunker, *args, **kwargs)
return wrapper
return cr
def packetreader(filtered=True, progress=True, mag_hist=False, row_hist=False, err_hist=False, pass_progress=False, loop=False, dup_stdin=False):
if filtered == 'data':
filterdec = dcnparams
else:
filterdec = filterparams(filtered)
def pr(f):
@chunkreader(loop=loop, dup_stdin=dup_stdin)
@click.option('--wst', is_flag=True, default=False, help='Input is 43 bytes per packet (WST capture card format.)')
@click.option('--ts', type=BasedInt, default=None, help='Input is MPEG transport stream. (Specify PID to extract.)')
@filterdec
@progressparams(progress=(progress and not loop), mag_hist=mag_hist, row_hist=row_hist, err_hist=err_hist)
@wraps(f)
def wrapper(chunker, wst, ts, progress, *args, **kwargs):
if wst and (ts is not None):
raise click.UsageError('--wst and --ts can not be specified at the same time.')
if wst:
chunks = chunker(43)
chunks = ((c[0],c[1][:42]) for c in chunks if c[1][0] != 0)
elif ts is not None:
from teletext.ts import pidextract
chunks = chunker(188)
chunks = pidextract(chunks, ts)
else:
chunks = chunker(42)
if progress is None:
progress = True
mag_hist = kwargs.pop('mag_hist', None)
row_hist = kwargs.pop('row_hist', None)
err_hist = kwargs.pop('err_hist', None)
if progress:
chunks = tqdm(chunks, unit='P', dynamic_ncols=True)
if pass_progress or any((mag_hist, row_hist, err_hist)):
chunks.postfix = StatsList()
packets = (Packet(data, number) for number, data in chunks)
if 'mags' in kwargs and 'rows' in kwargs:
mags = kwargs.pop('mags')
rows = kwargs.pop('rows')
packets = (p for p in packets if p.mrag.magazine in mags and p.mrag.row in rows)
elif 'dcn' in kwargs:
dcn = kwargs.pop('dcn')
mags = (dcn & 0x7,)
rows = (30 + (dcn>>3),)
packets = (p for p in packets if p.mrag.magazine in mags and p.mrag.row in rows)
if progress:
if mag_hist:
packets = MagHistogram(packets)
chunks.postfix.append(packets)
if row_hist:
packets = RowHistogram(packets)
chunks.postfix.append(packets)
if err_hist:
packets = ErrorHistogram(packets)
chunks.postfix.append(packets)
if pass_progress:
return f(progress=chunks, packets=packets, *args, **kwargs)
else:
return f(packets=packets, *args, **kwargs)
return wrapper
return pr
def paginated(always=False, filtered=True):
def _paginated(f):
@wraps(f)
def wrapper(*args, **kwargs):
paginate = always or kwargs['paginate']
if filtered:
pages = kwargs['pages']
if pages is None or len(pages) == 0:
pages = range(0x900)
else:
pages = {int(x, 16) for x in pages}
paginate = True
kwargs['pages'] = pages
subpages = kwargs['subpages']
if subpages is None or len(subpages) == 0:
subpages = range(0x3f80)
else:
subpages = {int(x, 16) for x in subpages}
paginate = True
kwargs['subpages'] = subpages
if paginate and 0 not in kwargs['rows']:
raise click.BadArgumentUsage("Can't paginate when row 0 is filtered.")
if not always:
kwargs['paginate'] = paginate
return f(*args, **kwargs)
if filtered:
wrapper = click.option('-s', '--subpage', 'subpages', type=str, multiple=True,
help='Limit output to specific subpage. Can be specified multiple times.')(wrapper)
wrapper = click.option('-p', '--page', 'pages', type=str, multiple=True,
help='Limit output to specific page. Can be specified multiple times.')(wrapper)
if not always:
wrapper = click.option('-P', '--paginate', is_flag=True, help='Sort rows into contiguous pages.')(wrapper)
return wrapper
return _paginated
def packetwriter(f):
@click.option(
'-o', '--output', type=(click.Choice(['auto', 'text', 'ansi', 'debug', 'bar', 'bytes', 'hex', 'vbi']), click.File('wb')),
multiple=True, default=[('auto', '-')]
)
@wraps(f)
def wrapper(output, *args, **kwargs):
if 'progress' in kwargs and kwargs['progress'] is None:
for attr, o in output:
if o.isatty():
kwargs['progress'] = False
packets = f(*args, **kwargs)
for attr, o in output:
packets = pipeline.to_file(packets, o, attr)
for p in packets:
pass
return wrapper
================================================
FILE: teletext/cli/teletext.py
================================================
import itertools
import multiprocessing
import os
import pathlib
import platform
import sys
from collections import defaultdict
import click
from tqdm import tqdm
from teletext.charset import g0
from teletext.cli.clihelpers import packetreader, packetwriter, paginated, \
progressparams, filterparams, carduser, chunkreader
from teletext.file import FileChunker
from teletext.mp import itermap
from teletext.packet import Packet, np
from teletext.stats import StatsList, MagHistogram, RowHistogram, Rejects, ErrorHistogram
from teletext.subpage import Subpage
from teletext import pipeline
from teletext.cli.training import training
from teletext.cli.vbi import vbi
from teletext.vbi.config import Config
if os.name == 'nt' and platform.release() == '10' and platform.version() >= '10.0.14393':
# Fix ANSI color in Windows 10 version 10.0.14393 (Windows Anniversary Update)
import ctypes
kernel32 = ctypes.windll.kernel32
kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
@click.group(invoke_without_command=True, no_args_is_help=True)
@click.option('-u', '--unicode', is_flag=True, help='Use experimental Unicode 13.0 Terminal graphics.')
@click.version_option()
@click.help_option()
@click.option('--help-all', is_flag=True, help='Show help for all subcommands.')
@click.pass_context
def teletext(ctx, unicode, help_all):
"""Teletext stream processing toolkit."""
if help_all:
print(teletext.get_help(ctx))
def help_recurse(group, ctx):
for scmd in group.list_commands(ctx):
cmd = group.get_command(ctx, scmd)
nctx = click.Context(cmd, ctx, scmd)
if isinstance(cmd, click.Group):
help_recurse(cmd, nctx)
else:
click.echo()
click.echo(cmd.get_help(nctx))
help_recurse(teletext, ctx)
if unicode:
from teletext import parser
parser._unicode13 = True
teletext.add_command(training)
teletext.add_command(vbi)
try:
from teletext.cli.celp import celp
teletext.add_command(celp)
except ImportError:
pass
@teletext.command()
@packetwriter
@paginated()
@click.option('--pagecount', 'n', type=int, default=0, help='Stop after n pages. 0 = no limit. Implies -P.')
@click.option('-k', '--keep-empty', is_flag=True, help='Keep empty packets in the output.')
@packetreader()
def filter(packets, pages, subpages, paginate, n, keep_empty):
"""Demultiplex and display t42 packet streams."""
if n:
paginate = True
if not keep_empty:
packets = (p for p in packets if not p.is_padding())
if paginate:
for pn, pl in enumerate(pipeline.paginate(packets, pages=pages, subpages=subpages), start=1):
yield from pl
if pn == n:
return
else:
yield from packets
@teletext.command()
@packetwriter
@paginated()
@click.argument('regex', type=str)
@click.option('-v', is_flag=True, help='Invert matches.')
@click.option('-i', is_flag=True, help='Ignore case.')
@click.option('--pagecount', 'n', type=int, default=0, help='Stop after n pages. 0 = no limit. Implies -P.')
@click.option('-k', '--keep-empty', is_flag=True, help='Keep empty packets in the output.')
@packetreader()
def grep(packets, pages, subpages, paginate, regex, v, i, n, keep_empty):
"""Filter packets with a regular expression."""
import re
pattern = re.compile(regex.encode('ascii'), re.IGNORECASE if i else 0)
if n:
paginate = True
if not keep_empty:
packets = (p for p in packets if not p.is_padding())
if paginate:
for pn, pl in enumerate(pipeline.paginate(packets, pages=pages, subpages=subpages), start=1):
for p in pl:
if bool(v) != bool(re.search(pattern, p.to_bytes_no_parity())):
yield from pl
if pn == n:
return
else:
for p in packets:
if bool(v) != bool(re.search(pattern, p.to_bytes_no_parity())):
yield p
@teletext.command(name='list')
@click.option('-c', '--count', is_flag=True, help='Show counts of each entry.')
@click.option('-s', '--subpages', is_flag=True, help='Also list subpages.')
@paginated(always=True, filtered=False)
@packetreader()
@progressparams(progress=True, mag_hist=True)
def _list(packets, count, subpages):
"""List pages present in a t42 stream."""
import textwrap
packets = (p for p in packets if not p.is_padding())
seen = {}
try:
for pl in pipeline.paginate(packets):
s = Subpage.from_packets(pl)
identifier = f'{s.mrag.magazine}{s.header.page:02x}'
if subpages:
identifier += f':{s.header.subpage:04x}'
if identifier in seen:
seen[identifier]+=1
else:
seen[identifier]=1
except KeyboardInterrupt:
print('\n')
finally:
if count:
maxdigits = len(str(max(seen.values())))
formatstr="{page}/{count:0" + str(maxdigits) +"}"
else:
formatstr="{page}"
seen = list(map(lambda e: formatstr.format(page = e[0], count = e[1]), seen.items()))
print('\n'.join(textwrap.wrap(' '.join(sorted(seen)))))
@teletext.command()
@click.argument('pattern')
@paginated(always=True)
@packetreader()
def split(packets, pattern, pages, subpages):
"""Split a t42 stream in to multiple files."""
packets = (p for p in packets if not p.is_padding())
counts = defaultdict(int)
for pl in pipeline.paginate(packets, pages=pages, subpages=subpages):
subpage = Subpage.from_packets(pl)
m = subpage.mrag.magazine
p = subpage.header.page
s = subpage.header.subpage
c = counts[(m,p,s)]
counts[(m,p,s)] += 1
f = pathlib.Path(pattern.format(m=m, p=f'{p:02x}', s=f'{s:04x}', c=f'{c:04d}'))
f.parent.mkdir(parents=True, exist_ok=True)
with f.open('ab') as ff:
ff.write(b''.join(p.bytes for p in pl))
@teletext.command()
@click.argument('a', type=click.File('rb'))
@click.argument('b', type=click.File('rb'))
@filterparams()
def diff(a, b, mags, rows):
"""Show side by side difference of two t42 streams."""
for chunka, chunkb in zip(FileChunker(a, 42), FileChunker(b, 42)):
pa = Packet(chunka[1], chunka[0])
pb = Packet(chunkb[1], chunkb[0])
if (pa.mrag.row in rows and pa.mrag.magazine in mags) or (pb.mrag.row in rows and pa.mrag.magazine in mags):
if any(pa[:] != pb[:]):
print(pa.to_ansi(), pb.to_ansi())
@teletext.command()
@packetwriter
@packetreader()
def finders(packets):
"""Apply finders to fix up common packets."""
for p in packets:
if p.type == 'header':
p.header.apply_finders()
yield p
@teletext.command()
@packetreader(filtered=False)
@click.option('-l', '--lines', type=int, default=32, help='Number of recorded lines per frame.')
@click.option('-f', '--frames', type=int, default=250, help='Number of frames to squash.')
def scan(packets, lines, frames):
"""Filter a t42 stream down to headers and bsdp, with squashing."""
from teletext.pipeline import packet_squash, bsdp_squash_format1, bsdp_squash_format2
bars = '_:|I'
while True:
actives = np.zeros((lines,), dtype=np.uint32)
headers = [[], [], [], [], [], [], [], [], []]
service = [[], []]
start = None
try:
for i in range(frames):
for n, p in enumerate(itertools.islice(packets, lines)):
if start is None:
start = p._number
if not p.is_padding():
if p.type == 'header':
p.header.apply_finders()
actives[n] += 1
if p.mrag.row == 0:
headers[p.mrag.magazine].append(p)
elif p.mrag.row == 30 and p.mrag.magazine == 8:
if p.broadcast.dc in [0, 1]:
service[0].append(p)
elif p.broadcast.dc in [2, 3]:
service[1].append(p)
except StopIteration:
pass
if start is None:
return
active_group = 1*(actives>0) + 1*(actives>(frames/2)) + 1*(actives==frames)
print(f'{start:8d}', '['+''.join(bars[a] for a in active_group)+']', end=' ')
for h in headers:
if h:
print(packet_squash(h).header.displayable.to_ansi(), end=' ')
break
for s in service:
if s:
print(packet_squash(s).broadcast.displayable.to_ansi(), end=' ')
break
if service[0]:
print(bsdp_squash_format1(service[0]), end=' ')
if service[1]:
print(bsdp_squash_format2(service[1]), end=' ')
print()
@teletext.command()
@click.option('-d', '--min-duplicates', type=int, default=3, help='Only squash and output subpages with at least N duplicates.')
@click.option('-t', '--threshold', type=int, default=-1, help='Max difference for squashing.')
@click.option('-i', '--ignore-empty', is_flag=True, default=False, help='Ignore the emptiest duplicate packets instead of the earliest.')
@packetwriter
@paginated(always=True)
@packetreader()
def squash(packets, min_duplicates, threshold, pages, subpages, ignore_empty):
"""Reduce errors in t42 stream by using frequency analysis."""
packets = (p for p in packets if not p.is_padding())
for sp in pipeline.subpage_squash(
pipeline.paginate(packets, pages=pages, subpages=subpages),
min_duplicates=min_duplicates, ignore_empty=ignore_empty,
threshold=threshold
):
yield from sp.packets
@teletext.command()
@click.option('-l', '--language', default='en_GB', help='Language. Default: en_GB')
@click.option('-b', '--both', is_flag=True, help='Show packet before and after corrections.')
@click.option('-t', '--threads', type=int, default=multiprocessing.cpu_count(), help='Number of threads.')
@packetwriter
@packetreader()
def spellcheck(packets, language, both, threads):
"""Spell check a t42 stream."""
try:
from teletext.spellcheck import spellcheck_packets
except ModuleNotFoundError as e:
if e.name == 'enchant':
raise click.UsageError(f'{e.msg}. PyEnchant is not installed. Spelling checker is not available.')
else:
raise e
else:
if both:
packets, orig_packets = itertools.tee(packets, 2)
packets = itermap(spellcheck_packets, packets, threads, language=language)
try:
while True:
yield next(orig_packets)
yield next(packets)
except StopIteration:
pass
else:
yield from itermap(spellcheck_packets, packets, threads, language=language)
@teletext.command()
@click.option('-r', '--replace_headers', 'replace_headers', is_flag=True, default=False, help='Replace headers with a live clock.')
@click.option('-t', '--title', 'title', type=str, default="Teletext ", help='Replace header title field with this string.')
@packetwriter
@paginated(always=True, filtered=False)
@packetreader()
def service(packets, replace_headers, title):
"""Build a service carousel from a t42 stream."""
from teletext.service import Service
return Service.from_packets((p for p in packets if not p.is_padding()), replace_headers, title)
@teletext.command()
@click.option('-r', '--replace_headers', 'replace_headers', is_flag=True, default=False, help='Replace headers with a live clock.')
@click.option('-t', '--title', 'title', type=str, default=None, help='Replace header title field with this string.')
@packetwriter
@click.argument('directory', type=click.Path(exists=True, readable=True, file_okay=False, dir_okay=True))
def servicedir(directory, replace_headers, title):
"""Build a service from a directory of t42 files."""
from teletext.servicedir import ServiceDir
with ServiceDir(directory, replace_headers, title) as s:
yield from s
@teletext.command()
@click.option('-i', '--initial_page', 'initial_page', type=str, default='100', help='Initial page.')
@packetreader(loop=True, dup_stdin=True)
def interactive(packets, initial_page):
"""Interactive teletext emulator."""
from teletext import interactive
interactive.main(packets, int(initial_page, 16))
@teletext.command()
@packetreader(loop=True)
@click.option('-p', '--port', type=str, default=None)
def serial(packets, port):
"""Write escaped packets to serial inserter."""
import serial.tools.list_ports
import time
if port is None:
for comport in serial.tools.list_ports.comports():
if comport.vid == 0x2e8a and (comport.pid == 0x000a or comport.pid == 0x0009):
port = comport.device
if port is None:
raise click.UsageError('No serial inserter found. Specify the path with -p')
port = serial.Serial(port, timeout=3, rtscts=True)
for p in packets:
buf = p.bytes
buf = buf.replace(b'\xfe', b'\xfe\x00')
buf = buf.replace(b'\xff', b'\xfe\x01')
buf = b'\xff' + buf
port.write(buf)
@teletext.command()
@click.option('-e', '--editor', type=str, default='https://zxnet.co.uk/teletext/editor/#',
show_default=True, help='Teletext editor URL.')
@paginated(always=True)
@packetreader()
def urls(packets, editor, pages, subpages):
"""Paginate a t42 stream and print edit.tf URLs."""
packets = (p for p in packets if not p.is_padding())
subpages = (Subpage.from_packets(pl) for pl in pipeline.paginate(packets, pages=pages, subpages=subpages))
for s in subpages:
print(f'{editor}{s.url}')
@teletext.command()
@click.argument('outdir', type=click.Path(exists=True, file_okay=False, dir_okay=True, writable=True), required=True)
@click.option('-f', '--font', type=click.File('rb'), help='PCF font for rendering.')
@paginated(always=True)
@packetreader()
def images(packets, outdir, font, pages, subpages):
"""Generate images for the input stream."""
try:
from teletext.image import subpage_to_image, load_glyphs
except ModuleNotFoundError as e:
if e.name == 'PIL':
raise click.UsageError(
f'{e.msg}. PIL is not installed. Image generation is not available.')
else:
raise e
from teletext.service import Service
glyphs = load_glyphs(font)
packets = (p for p in packets if not p.is_padding())
svc = Service.from_packets(p for p in packets if not p.is_padding())
subpages = tqdm(list(svc.all_subpages), unit="subpage")
for s in subpages:
image = subpage_to_image(s, glyphs)
filename = f'P{s.mrag.magazine}{s.header.page:02x}-{s.header.subpage:04x}.png'
subpages.set_description(filename, refresh=False)
if image._flash_used:
opts = {
'save_all': True,
'append_images': [subpage_to_image(s, glyphs, flash_off=True)],
'duration': 500,
'loop': 0,
'disposal': 2,
}
else:
opts = {}
image.save(pathlib.Path(outdir) / filename, **opts)
if image._missing_glyphs:
missing = ', '.join(f'{repr(c)} {hex(ord(c))}' for c in image._missing_glyphs)
print(f'{filename} missing characters: {missing}')
@teletext.command()
@click.argument('outdir', type=click.Path(exists=True, file_okay=False, dir_okay=True, writable=True), required=True)
@click.option('-t', '--template', type=click.File('r'), default=None, help='HTML template.')
@click.option('--localcodepage', type=click.Choice(g0.keys()), default=None, help='Select codepage for Local Code of Practice')
@paginated(always=True, filtered=False)
@packetreader()
def html(packets, outdir, template, localcodepage):
"""Generate HTML files from the input stream."""
from teletext.service import Service
if template is not None:
template = template.read()
svc = Service.from_packets(p for p in packets if not p.is_padding())
svc.to_html(outdir, template, localcodepage)
@teletext.command()
@click.argument('output', type=click.File('wb'), default='-')
@click.option('-d', '--device', type=click.File('rb'), default='/dev/vbi0', help='Capture device.')
@carduser()
def record(output, device, config):
"""Record VBI samples from a capture device."""
import struct
import sys
if output.name.startswith('/dev/vbi'):
raise click.UsageError(f'Refusing to write output to VBI device. Did you mean -d?')
chunks = FileChunker(device, config.line_length*config.field_lines*2)
bar = tqdm(chunks, unit=' Frames')
prev_seq = None
dropped = 0
try:
for n, chunk in bar:
output.write(chunk)
if config.card == 'bt8x8':
seq, = struct.unpack('>5)
print(f'[{bi}] [{by}]')
@training.command()
@click.argument('input', type=click.File('rb'), required=True)
@click.argument('output', type=click.File('wb'), required=True)
@click.option('-m', '--mode', type=click.Choice(['full', 'parity', 'hamming']), default='full')
@click.option('-b', '--bits', type=(int, int), default=(3, 21))
def build(input, output, mode, bits):
"""Build pattern tables."""
from teletext.coding import parity_encode, hamming8_enc
from teletext.vbi.pattern import build_pattern
if mode == 'parity':
pattern_set = set(parity_encode(range(0x80)))
elif mode == 'hamming':
pattern_set = set(hamming8_enc)
else:
pattern_set = range(256)
chunks = FileChunker(input, 27)
chunks = tqdm(chunks, unit='P', dynamic_ncols=True)
build_pattern(chunks, output, *bits, pattern_set)
@training.command()
@click.option('-f', '--tape-format', type=click.Choice(['vhs', 'betamax', 'grundig_2x4']), default='vhs', help='Source VCR format.')
def similarities(tape_format):
from teletext.vbi.pattern import Pattern
pattern = Pattern(os.path.dirname(__file__) + '/vbi/data-' + tape_format + '/parity.dat')
print(pattern.similarities())
@training.command()
@click.option('-t', '--threads', type=int, default=multiprocessing.cpu_count(), help='Number of threads.')
@carduser(extended=True)
@chunkreader()
@click.option('--progress/--no-progress', default=True, help='Display progress bar.')
@click.option('--rejects/--no-rejects', default=True, help='Display percentage of lines rejected.')
def crifc(chunker, config, threads, progress, rejects):
"""Split training recording into intermediate bins."""
from teletext.vbi.training import process_crifc
chunks = chunker(config.line_length * np.dtype(config.dtype).itemsize, config.field_lines, config.field_range)
if progress:
chunks = tqdm(chunks, unit='L', dynamic_ncols=True)
process_crifc(chunks, config=config)
================================================
FILE: teletext/cli/vbi.py
================================================
import click
import pathlib
import numpy as np
from tqdm import tqdm
from teletext.cli.clihelpers import carduser, chunkreader
@click.group()
def vbi():
"""Tools for analysing raw VBI samples."""
pass
@vbi.command()
@click.argument('output', type=click.Path(writable=True))
@click.option('-d', '--diff', is_flag=True, help='User first differential of samples.')
@click.option('-s', '--show', is_flag=True, help='Show image when complete.')
@click.option('-n', '--n-lines', type=int, default=None, help='Number of lines to display. Overrides card config.')
@carduser(extended=True)
@chunkreader()
def histogram(output, diff, show, chunker, config, n_lines):
from PIL import Image
import colorsys
n_lines = n_lines or len(list(config.field_range))*2
line_length = config.line_length - (1 if diff else 0)
result = np.zeros((n_lines, 256, line_length), dtype=np.uint32)
sel = np.arange(line_length)
chunks = chunker(config.line_length * np.dtype(config.dtype).itemsize, config.field_lines, config.field_range)
chunks = tqdm(chunks, unit='L', dynamic_ncols=True)
for n, d in chunks:
l = np.frombuffer(d, dtype=config.dtype) >> ((np.dtype(config.dtype).itemsize - 1) * 8)
if diff:
l = np.diff(l) + 128
result[n%n_lines, l, sel] += 1
for i in range(n_lines):
for j in range(line_length):
result[i,:,j] = 255*result[i,:,j]/np.max(result[i,:,j])
# flip vertically
result = result[:,::-1,:].reshape(-1, line_length)
palette = np.zeros((256, 3), dtype=np.uint8)
palette[0] = [0, 0, 0]
for c in range(1, 256):
palette[c] = [n * 255 for n in colorsys.hsv_to_rgb(c/1025, 1, 1)]
rgb = palette[result]
rgb[0::256, :] += 100
rgb[0::32, :] = np.maximum(rgb[0::32, :], 32)
i = Image.fromarray(rgb)
if show:
i.show()
i.convert('RGB').save(output)
@vbi.command()
@carduser(extended=True)
@chunkreader()
def plot(chunker, config):
from teletext.gui.vbiplot import vbiplot
vbiplot(chunker, config)
@vbi.command()
@carduser(extended=True)
@click.argument('input', type=click.Path(readable=True), required=True)
@click.argument('sampledir', type=click.Path(writable=True), required=True)
@click.option('-a', '--auto', is_flag=True)
def classifygui(input, sampledir, auto, config):
from teletext.gui.classify import classify_gui
classify_gui(input, sampledir, auto, config)
@vbi.command()
@carduser()
@chunkreader()
@click.argument('output', type=click.File('wb'))
@click.option('--progress/--no-progress', default=True, help='Display progress bar.')
def copy(chunker, config, progress, output):
"""Copy input to output"""
chunks = chunker(config.line_length * np.dtype(config.dtype).itemsize, config.field_lines, config.field_range)
if progress:
chunks = tqdm(chunks, unit='L', dynamic_ncols=True)
for n, c in chunks:
output.write(c)
@vbi.command()
@carduser()
@chunkreader()
@click.argument('output', type=click.Path(), required=True)
@click.option('--progress/--no-progress', default=True, help='Display progress bar.')
def linesplit(chunker, config, progress, output):
"""Split VBI file into one file per line"""
chunks = chunker(config.line_length * np.dtype(config.dtype).itemsize, config.field_lines, config.field_range)
if progress:
chunks = tqdm(chunks, unit='L', dynamic_ncols=True)
output = pathlib.Path(output)
output.mkdir(parents=True, exist_ok=True)
files = [(output / f'{n:02x}.vbi').open("wb") for n in range(config.frame_lines)]
for number, chunk in chunks:
files[number % config.frame_lines].write(chunk)
@vbi.command()
@carduser()
@chunkreader()
@click.argument('output', type=click.Path(), required=True)
@click.option('--progress/--no-progress', default=True, help='Display progress bar.')
@click.option('--prefix', type=str, default="", help='Prefix for cluster file names.')
def cluster(chunker, config, progress, output, prefix):
"""Split VBI file into clusters of similar lines"""
import teletext.vbi.clustering
chunks = chunker(config.line_bytes, config.field_lines, config.field_range)
if progress:
chunks = tqdm(chunks, unit='L', dynamic_ncols=True)
output = pathlib.Path(output)
output.mkdir(parents=True, exist_ok=True)
teletext.vbi.clustering.batch_cluster(chunks, output, prefix, config.field_lines * 2)
@vbi.command()
@carduser()
@click.argument('map', type=click.File('rb'), required=True)
@click.argument('output', type=click.File('wb'), required=True)
def rendermap(config, map, output):
"""Render cluster map to image"""
import teletext.vbi.clustering
teletext.vbi.clustering.rendermap(config, map, output)
================================================
FILE: teletext/coding.py
================================================
# * Copyright 2016 Alistair Buxton
# *
# * License: This program is free software; you can redistribute it and/or
# * modify it under the terms of the GNU General Public License as published
# * by the Free Software Foundation; either version 3 of the License, or (at
# * your option) any later version. This program is distributed in the hope
# * that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# * GNU General Public License for more details.
"""Byte coding and error protection
Odd parity:
The high bit of each byte is set such that there are an odd number of bits in the byte.
Single bit errors can be detected.
Hamming 8/4:
P1 D1 P2 D2 P3 D3 P4 D4 (Transmission order, LSB first.)
Single bit errors can be identified and corrected. Double bit errors can be detected.
Hamming 24/16:
P1 P2 D1 P3 D2 D3 D4 P4 D5 D6 D7 D8 D9 D10 D11 P5 D12 D13 D14 D15 D16 D17 D18 P6
Single bit errors can be identified and corrected. Double bit errors
can be detected.
"""
import numpy as np
def thue_morse(n, even=True):
arr = np.array([even], dtype=bool)
for i in range(0, n):
arr = np.append(arr,~arr)
return arr
# hamming 8/4 encoding look up table
hamming8_enc = np.array([
0x15, 0x02, 0x49, 0x5e, 0x64, 0x73, 0x38, 0x2f, 0xd0, 0xc7, 0x8c, 0x9b, 0xa1, 0xb6, 0xfd, 0xea,
], dtype=np.uint8)
hamming8_enc.flags.writeable = False
# hamming 8/4 correctable errors occur when the input has even parity
hamming8_cor = thue_morse(8, even=True)
hamming8_cor.flags.writeable = False
# hamming 8/4 uncorrectable errors always have odd parity,
# but so do valid bytes.
hamming8_unc = thue_morse(8, even=False)
hamming8_unc[hamming8_enc] = False
hamming8_unc.flags.writeable = False
# hamming 8/4 decoding lookup table
hamming8_dec = np.array([
0x1, 0xf, 0x1, 0x1, 0xf, 0x0, 0x1, 0xf, 0xf, 0x2, 0x1, 0xf, 0xa, 0xf, 0xf, 0x7,
0xf, 0x0, 0x1, 0xf, 0x0, 0x0, 0xf, 0x0, 0x6, 0xf, 0xf, 0xb, 0xf, 0x0, 0x3, 0xf,
0xf, 0xc, 0x1, 0xf, 0x4, 0xf, 0xf, 0x7, 0x6, 0xf, 0xf, 0x7, 0xf, 0x7, 0x7, 0x7,
0x6, 0xf, 0xf, 0x5, 0xf, 0x0, 0xd, 0xf, 0x6, 0x6, 0x6, 0xf, 0x6, 0xf, 0xf, 0x7,
0xf, 0x2, 0x1, 0xf, 0x4, 0xf, 0xf, 0x9, 0x2, 0x2, 0xf, 0x2, 0xf, 0x2, 0x3, 0xf,
0x8, 0xf, 0xf, 0x5, 0xf, 0x0, 0x3, 0xf, 0xf, 0x2, 0x3, 0xf, 0x3, 0xf, 0x3, 0x3,
0x4, 0xf, 0xf, 0x5, 0x4, 0x4, 0x4, 0xf, 0xf, 0x2, 0xf, 0xf, 0x4, 0xf, 0xf, 0x7,
0xf, 0x5, 0x5, 0x5, 0x4, 0xf, 0xf, 0x5, 0x6, 0xf, 0xf, 0x5, 0xf, 0xe, 0x3, 0xf,
0xf, 0xc, 0x1, 0xf, 0xa, 0xf, 0xf, 0x9, 0xa, 0xf, 0xf, 0xb, 0xa, 0xa, 0xa, 0xf,
0x8, 0xf, 0xf, 0xb, 0xf, 0x0, 0xd, 0xf, 0xf, 0xb, 0xb, 0xb, 0xa, 0xf, 0xf, 0xb,
0xc, 0xc, 0xf, 0xc, 0xf, 0xc, 0xd, 0xf, 0xf, 0xc, 0xf, 0xf, 0xa, 0xf, 0xf, 0x7,
0xf, 0xc, 0xd, 0xf, 0xd, 0xf, 0xd, 0xd, 0x6, 0xf, 0xf, 0xb, 0xf, 0xe, 0xd, 0xf,
0x8, 0xf, 0xf, 0x9, 0xf, 0x9, 0x9, 0x9, 0xf, 0x2, 0xf, 0xf, 0xa, 0xf, 0xf, 0x9,
0x8, 0x8, 0x8, 0xf, 0x8, 0xf, 0xf, 0x9, 0x8, 0xf, 0xf, 0xb, 0xf, 0xe, 0x3, 0xf,
0xf, 0xc, 0xf, 0xf, 0x4, 0xf, 0xf, 0x9, 0xf, 0xf, 0xf, 0xf, 0xf, 0xe, 0xf, 0xf,
0x8, 0xf, 0xf, 0x5, 0xf, 0xe, 0xd, 0xf, 0xf, 0xe, 0xf, 0xf, 0xe, 0xe, 0xf, 0xe,
], dtype=np.uint8)
hamming8_dec.flags.writeable = False
# odd parity bits
parity_tab = thue_morse(7, even=True) * 0x80
parity_tab.flags.writeable = False
# bit reverse
reverse8_tab = np.packbits(np.unpackbits(np.array(range(256), dtype=np.uint8)).reshape((-1, 8))[:,::-1])
reverse8_tab.flags.writeable = False
def hamming8_encode(a):
return hamming8_enc[a]
def hamming8_decode(a):
return hamming8_dec[a]
def hamming16_encode(a):
return np.ravel(np.column_stack((
hamming8_enc[a & 0xf],
hamming8_enc[a >> 4],
)))
def hamming16_decode(a):
if len(a) == 2:
return hamming8_dec[a[0]] | (hamming8_dec[a[1]] << 4)
else:
return hamming8_dec[a[0::2]] | (hamming8_dec[a[1::2]] << 4)
def hamming8_correctable_errors(a):
return hamming8_cor[a]
def hamming8_uncorrectable_errors(a):
return hamming8_unc[a]
def hamming8_errors(a):
return (2 * hamming8_unc[a]) + hamming8_cor[a]
def parity_encode(a):
return a | parity_tab[a]
def parity_decode(a):
return a & 0x7f
def parity_errors(a):
return parity_tab[a&0x7f] != a&0x80
def bcd8_decode(a):
tens = ((a >> 4) - 1) & 0xf
units = ((a & 0xf) - 1) & 0xf
return (tens * 10) + units
def bcd8_encode(a):
tens = ((a / 10) + 1) & 0xf
units = ((a % 10) + 1) & 0xf
return (tens << 4) | units
def byte_reverse(a):
return reverse8_tab[a]
def crc(n, c):
for b in range(7, -1, -1):
r = ((n >> b) & 1) ^ ((c >> 6) & 1) ^ ((c >> 8) & 1) ^ ((c >> 11) & 1) ^ ((c >> 15) & 1)
c = r | ((c & 0x7FFF) << 1)
return c
================================================
FILE: teletext/elements.py
================================================
import datetime
from .printer import PrinterANSI
from .coding import *
from . import finders
class Element(object):
def __init__(self, shape, array=None):
if array is None:
self._array = np.zeros(shape, dtype=np.uint8)
elif type(array) == bytes:
self._array = np.frombuffer(array, dtype=np.uint8).copy()
else:
self._array = array
if self._array.shape != shape:
raise IndexError('Element got wrong shaped data.')
def __getitem__(self, item):
return self._array[item]
def __setitem__(self, item, value):
self._array[item] = value
def __repr__(self):
return f'{self.__class__.__name__}({repr(self._array)})'
@property
def bytes(self):
return self._array.tobytes()
@property
def sevenbit(self):
return np.packbits(np.unpackbits(self._array).reshape(-1, 8)[:, 1:].flatten()).tobytes()
@sevenbit.setter
def sevenbit(self, b):
a = np.frombuffer(b, dtype=np.uint8)
s = self._array.size * 7
u = np.unpackbits(a)[:s].reshape(-1, 7)
self._array[:] = np.packbits(u, axis=-1).reshape(self._array.shape) >> 1
@property
def errors(self):
raise NotImplementedError
class ElementParity(Element):
@property
def errors(self):
return parity_errors(self._array)
class ElementHamming(Element):
@property
def errors(self):
return hamming8_errors(self._array)
class Mrag(ElementHamming):
def __init__(self, array=None):
super().__init__((2,), array)
@property
def magazine(self):
magazine = hamming8_decode(self._array[0]) & 0x7
return magazine or 8
@property
def row(self):
return hamming16_decode(self._array[:2]) >> 3
@magazine.setter
def magazine(self, magazine):
if magazine < 0 or magazine > 8:
raise ValueError('Magazine numbers must be between 0 and 8.')
self._array[0] = hamming8_encode((magazine & 0x7) | ((self.row&0x1) << 3))
@row.setter
def row(self, row):
if row < 0 or row > 31:
raise ValueError('Row numbers must be between 0 and 31.')
self._array[0] = hamming8_encode((self.magazine & 0x7) | ((row & 0x1) << 3))
self._array[1] = hamming8_encode(row >> 1)
def __str__(self):
return f'{self.magazine} {self.row} {self.errors}'
class Displayable(ElementParity):
def place_string(self, string, x=0, y=None):
if isinstance(string, str):
string = string.encode('ascii')
a = np.frombuffer(string, dtype=np.uint8)
if y is None:
self._array[x:x+a.shape[0]] = parity_encode(a)
else:
self._array[y, x:x + a.shape[0]] = parity_encode(a)
def place_bitmap(self, bitmap, x=1, y=0, colour=0x17, conceal=False):
yp, xp = bitmap.shape
yr = yp % 3
xr = xp % 2
if yr or xr:
bitmap = np.pad(bitmap, ((0, (3-yr)%3), (0, (2-xr)%2)))
yp, xp = bitmap.shape
yc = yp // 3
xc = xp // 2
mosaic = bitmap.reshape(yc, 3, xc, 2).swapaxes(1, 2).reshape(yc, xc, 6)
mosaic = np.packbits(mosaic, axis=2, bitorder="little").reshape(yc, xc)
mosaic = mosaic + ((mosaic >= 0x20) * 0x20) + 0x20
if conceal:
self._array[y:y+yc, x-2] = parity_encode(colour)
self._array[y:y+yc, x-1] = parity_encode(0x18)
else:
self._array[y:y+yc, x-1] = parity_encode(colour)
self._array[y:y+yc, x:x+xc] = parity_encode(mosaic)
def to_ansi(self, colour=True):
if len(self._array.shape) == 1:
return str(PrinterANSI(self._array, colour))
else:
return '\n'.join(
[str(PrinterANSI(a, colour)) for a in self._array]
)
def _tti_escape(self, array):
return ''.join(f'\x1b{chr(x | 0x40)}' if 0 <= x <= 0x1f else chr(x) for x in (array & 0x7f))
def to_tti(self):
if len(self._array.shape) == 1:
return self._tti_escape(self._array)
else:
return [self._tti_escape(a) for a in self._array]
@property
def bytes_no_parity(self):
return (self._array & 0x7f).tobytes()
class Page(Element):
@property
def page(self):
return hamming16_decode(self._array[:2])
@page.setter
def page(self, page):
if page < 0 or page > 0xff:
raise ValueError('Page numbers must be between 0 and 0xff.')
self._array[:2] = hamming16_encode(page)
@property
def errors(self):
e = np.zeros_like(self._array)
e[:2] = hamming8_errors(self._array[:2])
return e
class Header(Page):
def __init__(self, array):
super().__init__((40,), array)
@property
def subpage(self):
values = hamming16_decode(self._array[2:6])
return (values[0] & 0x7f) | ((values[1] & 0x3f) <<8)
@property
def control(self):
values = hamming16_decode(self._array[2:8])
return (values[0] >> 7) | ((values[1] >> 5) & 0x6) | (values[2] << 3)
@property
def displayable(self):
return Displayable((32,), self._array[8:])
@property
def codepage(self):
return (self.control >> 8) & 0x7
@property
def newsflash(self):
return bool(self.control & 0x2)
@property
def subtitle(self):
return bool(self.control & 0x4)
@property
def supress_header(self):
return bool(self.control & 0x8)
@subpage.setter
def subpage(self, subpage):
if subpage < 0 or subpage > 0x3f7f:
raise ValueError('Subpage numbers must be between 0 and 0x3f7f.')
control = self.control
self._array[2:6] = hamming16_encode(np.array([
(subpage & 0x7f) | ((control & 1) << 7),
(subpage >> 8) | ((control & 6) << 6),
], dtype=np.uint8))
@control.setter
def control(self, control):
if control < 0 or control > 2047:
raise ValueError('Control bits must be between 0 and 2047.')
subpage = self.subpage
self._array[3] = hamming8_encode(((subpage >> 4) & 0x7) | ((control & 1) << 3))
self._array[5] = hamming8_encode(((subpage >> 12) & 0x3) | ((control & 6) << 1))
self._array[6:8] = hamming16_encode(control >> 3)
def to_ansi(self, colour=True):
return f'{self.page:02x} {self.displayable.to_ansi(colour)}'
def apply_finders(self):
ranks = [(f.match(self.displayable[:]),f) for f in finders.HeaderFinders]
ranks.sort(reverse=True, key=lambda x: x[0])
if ranks[0][0] > 20:
self.finder = ranks[0][1]
self.finder.fixup(self.displayable[:])
@property
def errors(self):
e = super().errors
e[2:8] = hamming8_errors(self._array[2:8])
e[8:] = self.displayable.errors
return e
class Triplet(Element):
def __init__(self, array):
super().__init__((3,), array)
def __str__(self):
return f'triplet'
@property
def errors(self):
e = super().errors
e[:] = hamming8_errors(self._array[:])
return e
class PageLink(Page):
def __init__(self, array, mrag):
super().__init__((6,), array)
self._mrag = mrag
@property
def subpage(self):
values = hamming16_decode(self._array[2:6])
return (values[0] & 0x7f) | ((values[1] & 0x3f) <<8)
@property
def magazine(self):
values = hamming16_decode(self._array[2:6])
magazine = ((values[0] >> 7) | ((values[1] >> 5) & 0x6)) ^ (self._mrag.magazine & 0x7)
return magazine or 8
@subpage.setter
def subpage(self, subpage):
if subpage < 0 or subpage > 0x3f7f:
raise ValueError('Subpage numbers must be between 0 and 0x3f7f.')
magazine = self.magazine
self._array[2:6] = hamming16_encode(np.array([
(subpage & 0x7f) | ((magazine & 1) << 7),
(subpage >> 8) | ((magazine & 6) << 6),
]))
@magazine.setter
def magazine(self, magazine):
if magazine < 0 or magazine > 8:
raise ValueError('Magazine numbers must be between 0 and 8.')
magazine = magazine ^ self._mrag.magazine
subpage = self.subpage
self._array[3:6:2] = hamming8_encode([
((subpage >> 4) & 0x7) | ((magazine & 1) << 3),
((subpage >> 12) & 0x3) | ((magazine & 6) << 1),
])
def __str__(self):
return f'{self.magazine}{self.page:02x}:{self.subpage:04x}'
@property
def errors(self):
e = super().errors
e[:] = hamming8_errors(self._array[:])
return e
class DesignationCode(Element):
@property
def dc(self):
return hamming8_decode(self._array[0])
@dc.setter
def dc(self, dc):
self._array[0] = hamming8_encode(dc)
@property
def errors(self):
e = np.zeros_like(self._array)
e[0] = hamming8_errors(self._array[0])
return e
class Triplets(DesignationCode):
def __init__(self, array, mrag):
super().__init__((40,), array)
self._mrag = mrag
@property
def triplets(self):
return tuple(Triplet(self._array[n:n+3]) for n in range(1, 40, 3))
def to_ansi(self, colour=True):
return f'DC={self.dc:x} ' + ' '.join((str(triplet) for triplet in self.triplets))
@property
def errors(self):
e = super().errors
for t,n in zip(self.triplets, range(1, 40, 3)):
e[n:n+3] = t.errors
return e
class Fastext(DesignationCode):
def __init__(self, array, mrag):
super().__init__((40,), array)
self._mrag = mrag
@property
def links(self):
return tuple(PageLink(self._array[n:n+6], self._mrag) for n in range(1, 36, 6))
@property
def control(self):
return hamming8_decode(self._array[37])
@control.setter
def control(self, value):
self._array[37] = hamming8_encode(value)
@property
def checksum(self):
return self._array[38]<<8 | self._array[39]
@checksum.setter
def checksum(self, value):
self._array[38] = value>>8
self._array[39] = value&0xff
def to_ansi(self, colour=True):
return f'DC={self.dc:x} ' + ' '.join((str(link) for link in self.links)) + f' CRC={self.checksum:04x}'
@property
def errors(self):
e = super().errors
for l,n in zip(self.links, range(1, 36, 6)):
e[n:n+6] = l.errors
return e
class Format1(Element):
epoch = datetime.date(1858, 11, 17)
def __init__(self, array):
super().__init__((9,), array)
@property
def network(self):
return (byte_reverse(self._array[0]) << 8) | byte_reverse(self._array[1])
@property
def offset(self):
hours = 0.5 * ((self._array[2] >> 1) & 0x1f)
if ((self._array[2] >> 6) & 0x01):
hours *= -1
return hours
@property
def mjd(self):
return (bcd8_decode((int(self._array[3])&0xf)|0x10) * 10000) + (bcd8_decode(int(self._array[4])) * 100) + bcd8_decode(int(self._array[5]))
@property
def date(self):
return self.epoch + datetime.timedelta(days=int(self.mjd))
@property
def hour(self):
return bcd8_decode(self._array[6])
@hour.setter
def hour(self, value):
self._array[6] = bcd8_encode(value)
@property
def minute(self):
return bcd8_decode(self._array[7])
@minute.setter
def minute(self, value):
self._array[7] = bcd8_encode(value)
@property
def second(self):
return bcd8_decode(self._array[8])
@second.setter
def second(self, value):
self._array[8] = bcd8_encode(value)
def to_ansi(self, colour=True):
return f'NI={self.network:04x} {self.date} {self.hour:02d}:{self.minute:02d}:{self.second:02d} {self.offset}'
@property
def errors(self):
#TODO: detect invalid dates and times
return 0
class Format2(Element):
def __init__(self, array):
super().__init__((13,), array)
@property
def day(self):
return byte_reverse(((hamming16_decode(self._array[3:5]) >> 2) & 0x1f)) >> 3
@property
def month(self):
return byte_reverse((hamming16_decode(self._array[4:6]) >> 3) & 0x0f) >> 4
@property
def hour(self):
return byte_reverse((hamming16_decode(self._array[5:7]) >> 3) & 0x1f) >> 3
@property
def minute(self):
return byte_reverse(hamming16_decode(self._array[7:9]) & 0x3f) >> 2
@property
def country(self):
return byte_reverse(hamming8_decode(self._array[2]) | ((hamming8_decode(self._array[8]) & 0xC) << 2) | ((hamming8_decode(self._array[9]) & 0x3) << 6))
@property
def network(self):
return byte_reverse((hamming8_decode(self._array[3]) & 0x3) | (hamming8_decode(self._array[9]) & 0xC) | (hamming8_decode(self._array[10]) << 4))
def to_ansi(self, colour=True):
return f'NI={self.network:02x} C={self.country:02x} {self.day}/{self.month} {self.hour:02d}:{self.minute:02d}'
class BroadcastData(DesignationCode):
def __init__(self, array, mrag):
super().__init__((40,), array)
self._mrag = mrag
@property
def displayable(self):
return Displayable((20,), self._array[20:])
@property
def initial_page(self):
return PageLink(self._array[1:7], self._mrag)
@property
def format1(self):
return Format1(self._array[7:16])
@property
def format2(self):
return Format2(self._array[7:20])
def to_ansi(self, colour=True):
if self.dc in [0, 1]:
return f'{self.displayable.to_ansi(colour)} DC={self.dc} IP={self.initial_page} {self.format1.to_ansi(colour)}'
elif self.dc in [2, 3]:
return f'{self.displayable.to_ansi(colour)} DC={self.dc} IP={self.initial_page} {self.format2.to_ansi(colour)}'
else:
return f'DC={self.dc}'
@property
def errors(self):
e = super().errors
e[1:7] = self.initial_page.errors
e[20:] = self.displayable.errors
return e
class Celp(Element):
dblevels = [0, 4, 8, 12, 18, 24, 30, 0]
servicetypes = [
'Single-channel mode using 1 VBI line per frame',
'Single-channel mode using 2 VBI lines per frame',
'Single-channel mode using 3 VBI lines per frame',
'Single-channel mode using 4 VBI lines per frame',
'Mute Channel 1',
'Two-channel Mode using 2 VBI lines per frame',
'Mute Channel 2',
'Two-channel Mode using 4 VBI lines per frame',
]
fmt = 'DCN: {dcn}, {rel}, S: {svc:>7s}, C: {ctl} {frame0} {frame1}'
def __init__(self, array, mrag):
super().__init__((40,), array)
self._mrag = mrag
@property
def dcn(self):
return self._mrag.magazine + ((self._mrag.row & 1) << 3)
@property
def service(self):
return hamming8_decode(self._array[0])
@service.setter
def service(self, service):
self._array[0] = hamming8_encode(service)
@property
def control(self):
return hamming8_decode(self._array[1])
@control.setter
def control(self, service):
self._array[0] = hamming8_encode(service)
def to_ansi(self, colour=True):
frame0 = self._array[2:21].tobytes().hex()
frame1 = self._array[21:40].tobytes().hex()
if self.dcn == 4:
return self.fmt.format(
dcn = self.dcn,
rel = 'Programme-related audio',
svc = 'AUDETEL' if self.service == 0 else hex(self.service),
ctl = f'{hex(self.control)} {self.dblevels[self.control & 0x7]:2d} dB {"muted" if self.control & 0x8 else ""}',
frame0 = frame0,
frame1 = frame1,
)
elif self.dcn == 12:
if self.service & 0x8:
return self.fmt.format(
dcn=self.dcn,
rel='Programme-independent audio',
svc=f'User-defined {hex(self.service&0x7)}',
ctl=hex(self._array[1]),
frame0=frame0,
frame1=frame1,
)
else:
return self.fmt.format(
dcn=self.dcn,
rel='Programme-independent audio',
svc=self.servicetypes[self.service],
ctl=hex(self.control),
frame0=frame0,
frame1=frame1,
)
else:
raise ValueError("Unexpected data channel for CELP.")
@property
def errors(self):
e = np.zeros_like(self._array)
e[0:2] = hamming8_errors(self._array[0:2])
return e
================================================
FILE: teletext/file.py
================================================
import io
import itertools
import os
import stat
def PossiblyInfiniteRange(start=0, stop=None, step=1, limit=None):
if stop is None:
if limit is None:
return itertools.count(start, step)
else:
return range(start, start + (limit * step), step)
else:
if limit is None:
return range(start, stop, step)
else:
return range(start, min(stop, start + (limit * step)), step)
class LenWrapper(object):
def __init__(self, i, l):
self.i = i
self.l = l
def __iter__(self):
return self.i
def __len__(self):
return self.l
def _chunks(f, size, flines, frange, seek):
while True:
if seek:
f.seek(size * frange.start, os.SEEK_CUR)
else:
f.read(size * frange.start)
for _ in frange:
b = f.read(size)
if len(b) < size:
return
yield b
if seek:
f.seek(size * (flines - frange.stop), os.SEEK_CUR)
else:
f.read(size * (flines - frange.stop))
def chunks(f, size, start, step, flines=16, frange=(0, 16), seek=True):
while True:
c = _chunks(f, size, flines, frange, seek)
try:
for _ in range(start):
next(c)
while True:
yield next(c)
for i in range(step-1):
next(c)
except StopIteration:
if seek:
f.seek(0, os.SEEK_SET)
else:
return
def FileChunker(f, size, start=0, stop=None, step=1, limit=None, flines=16, frange=range(0, 16), loop=False, dup_stdin=False):
seekable = False
try:
if hasattr(f, 'fileno') and stat.S_ISFIFO(os.fstat(f.fileno()).st_mode):
if dup_stdin and f.fileno() == 0:
f = os.fdopen(os.dup(f.fileno()), 'rb')
raise io.UnsupportedOperation
f.seek(0, os.SEEK_END)
total_lines = f.tell() // size
total_fields = total_lines // flines
remainder = max(min((total_lines % flines) - frange.start, len(frange)), 0)
useful_lines = (total_fields * len(frange)) + remainder
if stop is None:
stop = useful_lines
else:
stop = min(stop, useful_lines)
seekable = True
f.seek(0, os.SEEK_SET)
except io.UnsupportedOperation:
# chunks() always seeks to the start
pass
r = PossiblyInfiniteRange(start, None if loop else stop, step, limit)
i = zip(r, chunks(f, size, start, step, flines, frange, seek=seekable))
if hasattr(r, '__len__'):
return LenWrapper(i, len(r))
else:
return i
================================================
FILE: teletext/finders.py
================================================
import itertools
from .coding import parity_encode, parity_decode
class Finder(object):
groups = {
'c': b'abcdefghijklmnopqrstuvwxyz ',
'C': b'ABCDEFGHIJKLMNOPQRSTUVWXYZ ',
'D': b'MTWFS',
'd': b'mtwfs',
'A': b'OUEHRA',
'a': b'ouehra',
'Y': b'NEDUIT',
'y': b'neduit',
'M': b'JFMASOND',
'm': b'jfmasond',
'O': b'AEPUCO',
'o': b'aepuco',
'N': b'NBRYNLGPTVC',
'n': b'nbrynlgptvc',
'Z': b'12345678',
'T': b'0123456789ABCDEFabcdef',
'U': b'0123456789ABCDEFabcdef',
'F': b'0123 ',
'f': b'0123456789',
'H': b'012 ',
'h': b'0123456789',
'L': b'012345',
'l': b'0123456789',
'S': b'012345',
's': b'0123456789',
'e': b'', # exact match
'*': b'', # wildcard
' ': b'\x00\x01\x02\x03\x04\x05\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f ', # whitespace/spacing attributes
}
def __init__(self, match1, match2, name, years, channels):
if len(match1) != len(match2):
raise ValueError('Match fields must be equal length.')
self.match1 = match1.encode('ascii')
self.match2 = match2
self.name = name
self.years = years
self.channels = channels
def match(self, arr):
a = [2 if (m2 == 'e' and m1 == c) else (1 if c in self.groups[m2] else 0) for m1, m2, c in zip(self.match1, self.match2, parity_decode(arr))]
#print(f'{self.name[:20]:21s}', ''.join(str(n) for n in a))
return sum(a)
def fixup(self, arr):
for n, m1, m2 in zip(itertools.count(), self.match1, self.match2):
if m2 == 'e':
arr[n] = parity_encode(m1)
HeaderFinders = [
Finder("CEEFAX 217 \x09Wed 25 Dec\x03 18:29/53",
"eeeeeeeZTUee"+"DayeFfeMone"+"eHheLleSs",
"BBC", (0,1996), ['BBC1', 'BBC2']),
Finder("CEEFAX 1 217 Wed 25 Dec\x0318:29/53",
"eeeeeeeeeZTUeDayeFfeMoneHhe"+"LleSs",
"BBC1", (1996,3000), ['BBC1']),
Finder("CEEFAX 2 217 Wed 25 Dec\x0318:29/53",
"eeeeeeeeeZTUeDayeFfeMone"+"HheLleSs",
"BBC2", (1996,3000), ['BBC2']),
Finder("Central 217 Wed 25 Dec 18:29:53",
"eeeeeeeeeZTUeDayeFfeMoneHheLleSs",
"Central", (0, 3000), ['ITV']),
Finder("\x02 ITV SUBTITLES ",
"e"+"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"ITV Subs.", (0, 3000), ['ITV']),
Finder("\x01\x1d\x07 DBI STATUS PAGE \x1c 2059:27",
"e"+"e"+"e"+"eeeeeeeeeeeeeeeeeeee"+"eeHhLleSs",
"ITV DBI Stat.", (0, 3000), ['ITV']),
Finder(" 2059:27",
"eeeeeeeeeeeeeeeeeeeeeeeeeHhLleSs",
"ITV DBI Blank", (0, 3000), ['ITV']),
Finder(" ",
"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"Subs Blank", (0, 3000), ['BBC1', 'BBC2', 'ITV', 'C4', 'Five']),
Finder("\x01789 DBI TEST PAGE 789\x07 2059:27",
"e"+"ZTUeeeeeeeeeeeeeeeZTUe"+"eeHhLleSs",
"ITV DBI Test.", (0, 3000), ['ITV']),
Finder("\x01 DBI/CH4 - BCAST2 \x09\x07 2059:27",
"e"+"ZTUeeeeeeeeeeeeeeeeeee"+"e"+"eHhLleSs",
"C4 DBI Test.", (0, 3000), ['C4']),
Finder(" 500 mon 12 may 2059:27",
"eZTUeeeeedayeFfeemoneeeeeHhLleSs",
"Five", (1997,1997), ['Five']),
Finder("\x06 5 text \x07255 02 May\x031835:21",
"e"+"eeeeeeeeeeeee"+"ZTUeFfeMone"+"HhLleSs",
"Five", (1997, 2006), ['Five']),
Finder("Five 500 27 Nov 20:59.27",
"eeeeeZTUeeFfeMonemoneeeeHheLleSs",
"Five", (1999,3000), ['Five']),
Finder("SOFTEL D1 SUBTITLE INSERTER ",
"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"Five Subs.", (0,3000), ['Five']),
Finder("\x04\x1d\x03Teletext\x07 \x1c100 May05\x0318:29:53",
"e"+"e"+"e"+"eeeeeeeee"+"ee"+"ZTUeMonFfe"+"HheLleSs",
"Teletext Ltd.", (1993, 3000), ['ITV', 'C4']),
Finder("\x04\x1d\x03Teletext \x03\x1c100 May05\x0318:29:53",
"e"+"e"+"e"+"eeeeeeeeee"+"e"+"ZTUeMonFfe"+"HheLleSs",
"Teletext Ltd. (Five)", (1999, 3000), ['Five']),
Finder("\x04\x1d\x03Teletext\x07 \x1c100\x03May05\x0318:29:53",
"e"+"e"+"e"+"eeeeeeeee"+"ee"+"ZTUe"+"MonFfe"+"HheLleSs",
"Teletext Ltd. (Five)", (1999, 3000), ['Five']),
Finder("4-Tel 307 Sun 26 May\x03C4\x0718:29:53",
"eeeeeeZTUeDayeFfeMone"+"eee"+"HheLleSs",
"4-Tel", (0, 3000), ['C4']),
Finder("PLEASE REFER TO PAGE 100 2001:01",
"eeeeeeeeeeeeeeeeeeeeeeeeeHhLleSs",
"Oracle Filler", (0,1992), ['ITV', 'C4']),
Finder("ORACLE 200 Sun27 Dec\x03ITV\x032001:01",
"eeeeeeeZTUeDayFfeMone"+"eeee"+"HhLleSs",
"Oracle (ITV)", (0,1992), ['ITV']),
Finder("Teletext on 4 100 Jan25\x0320:01:01",
"eeeeeeeeeeeeeeZTUeMonFfe"+"HheLleSs",
"Teletext Ltd. (C4 - Early)", (1993,1993), ['C4']),
Finder("100\x02ARD/ZDF\x07Mo 26.12.88\x0222:00:00",
"ZTUe"+"eeeeeeee"+"Daeeeeeeeeee"+"HheLleSs",
"ARD", (0,3000), ['ARD']),
Finder("102 BELTEK 22:07:06",
"ZTU CCCCCCCCCCCCCCCCCCC HheLleSs",
"TVR", (0,3000), ['ARD']),
]
================================================
FILE: teletext/gui/__init__.py
================================================
================================================
FILE: teletext/gui/classify.py
================================================
import pathlib
import sys
import numpy as np
from PyQt5 import QtWidgets, QtCore
import matplotlib.pyplot as plt
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
class Window(QtWidgets.QMainWindow):
def __init__(self, dir, sampledir, auto, config, app, parent=None):
super(Window, self).__init__(parent)
self.app = app
self.auto = auto
self.config = config
self.dir = pathlib.Path(dir)
self.sampledir = pathlib.Path(sampledir)
self.files = []
for f in self.dir.iterdir():
if f.is_file() and f.suffix == '.vbi':
s = f.stat().st_size // self.config.line_bytes
self.files.append((f, s))
elif f.is_dir():
for g in f.iterdir():
if g.is_file() and g.suffix == '.vbi':
s = g.stat().st_size // self.config.line_bytes
self.files.append((g, s))
print(len(self.files))
self.files.sort(key=lambda x: x[1], reverse=True)
#self.files = self.files[1000:]
self.current_file = 0
self.setWindowTitle("VBI Classify")
w = QtWidgets.QWidget(self)
self.setCentralWidget(w)
layout = QtWidgets.QVBoxLayout(w)
w.setLayout(layout)
f = QtWidgets.QFrame()
f.setFrameShape(QtWidgets.QFrame.Shape.Panel)
f.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken)
f.setLineWidth(2)
fl = QtWidgets.QVBoxLayout()
fl.setContentsMargins(0, 0, 0, 0)
f.setLayout(fl)
self.canvas = FigureCanvas()
fl.addWidget(self.canvas)
layout.addWidget(f, 1)
self.buttons = {}
btntmp = ['teletext', 'negatext', 'quiet', 'empty', 'noise', 'mixed']
if not self.auto:
self.g = QtWidgets.QWidget(w)
self.bbox = QtWidgets.QGridLayout(self.g)
self.g.setLayout(self.bbox)
layout.addWidget(self.g)
self.buttonMapper = QtCore.QSignalMapper(self)
self.buttonMapper.mappedString.connect(self.button_pressed)
for f in sorted(self.sampledir.iterdir()):
if f.is_dir():
if f.name not in btntmp:
btntmp.append(f.name)
btntmp.append('skip')
for y in range(10):
for x in range(5):
try:
self.add_button(btntmp[(y*5)+x], (y, x))
except IndexError:
break
self.progress = QtWidgets.QProgressBar()
self.progress.setVisible(False)
self.progress.setFixedWidth(200)
self.statusBar().addPermanentWidget(self.progress)
self.enable_buttons(False)
w.setLayout(layout)
self.resize(1000, 600)
self.show()
self.load()
def add_button(self, label, pos):
b = QtWidgets.QPushButton(self.g)
b.setText(label)
#b.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding)
b.setMinimumHeight(b.height()+10)
b.clicked.connect(self.buttonMapper.map)
self.buttonMapper.setMapping(b, label)
self.bbox.addWidget(b, *pos)
self.buttons[label] = b
def enable_buttons(self, en):
for b in self.buttons.values():
b.setEnabled(en)
def button_pressed(self, label):
if label != 'skip':
src = self.files[self.current_file][0]
(self.sampledir / label).mkdir(parents=True, exist_ok=True)
src.rename(self.sampledir / label / src.name)
print(self.files[self.current_file], '->', label)
self.current_file += 1
if self.current_file >= len(self.files):
print("All done")
self.app.quit()
else:
self.enable_buttons(False)
self.load()
def load(self):
f = self.files[self.current_file]
self.statusBar().showMessage(f'{self.current_file + 1}/{len(self.files)} - {f[0]} - {f[1]} lines')
try:
result = np.fromfile(f[0].with_suffix('.hist'), dtype=np.uint32).reshape(256, self.config.line_length)
self.plot(result)
if self.auto:
self.button_pressed('skip')
except FileNotFoundError:
self.load_thread = VBILoader(f[0], self.config)
self.load_thread.total.connect(self.progress.setMaximum)
self.load_thread.update.connect(self.progress.setValue)
self.load_thread.finished.connect(self.loaded)
self.progress.setValue(0)
self.load_thread.start()
self.progress.setVisible(True)
def loaded(self):
result = self.load_thread.result
del self.load_thread
self.progress.setVisible(False)
if self.auto:
f = self.files[self.current_file][0]
f = f.with_suffix('.hist')
result.tofile(f)
self.button_pressed('skip')
self.plot(result)
def plot(self, result):
fig = Figure()
ax = fig.subplots(1, 1)
aa = result == 0
mn = np.argmin(aa, axis=0)
mx = 255 - np.argmin(aa[::-1, :], axis=0)
ax.imshow(result, origin="lower", cmap="hot")
ax.plot(mn, linewidth=1)
ax.plot(mx, linewidth=1)
fig.tight_layout(pad=0)
self.canvas.figure = fig
x, y = self.width(), self.height()
self.resize(x+1, y+1)
self.resize(x, y)
# refresh canvas
self.canvas.draw()
plt.close(fig)
self.enable_buttons(True)
class VBILoader(QtCore.QThread):
total = QtCore.pyqtSignal(int)
update = QtCore.pyqtSignal(int)
def __init__(self, file, config):
self.file = file
self.config = config
super().__init__()
def run(self):
arr = np.memmap(self.file, dtype=self.config.dtype).reshape(-1, self.config.line_length)
h = np.zeros((256, self.config.line_length), dtype=np.uint32)
sel = np.arange(self.config.line_length)
shift = (np.dtype(self.config.dtype).itemsize - 1) * 8
self.total.emit(arr.shape[0])
for n in range(arr.shape[0]):
l = arr[n] >> shift
h[l, sel] += 1
self.update.emit(n)
for j in range(self.config.line_length):
h[:,j] = 255*h[:,j]/np.max(h[:,j])
self.result = h
def classify_gui(dir, sampledir, auto, config):
app = QtWidgets.QApplication.instance()
if app is None:
app = QtWidgets.QApplication(sys.argv)
main = Window(dir, sampledir, auto, config, app)
main.show()
app.exec_()
================================================
FILE: teletext/gui/decoder.py
================================================
import os
import random
import sys
import webbrowser
import numpy as np
from PyQt5.QtCore import QSize, QObject, QUrl
from PyQt5.QtGui import QFont, QColor
from teletext.parser import Parser
class Palette(object):
def __init__(self, context):
self._context = context
self._palette = [
QColor(0, 0, 0),
QColor(255, 0, 0),
QColor(0, 255, 0),
QColor(255, 255, 0),
QColor(0, 0, 255),
QColor(255, 0, 255),
QColor(0, 255, 255),
QColor(255, 255, 255),
]
self._context.setContextProperty('ttpalette', self._palette)
def __getitem__(self, item):
return (self._palette[item].red(), self._palette[item].green(), self._palette[item].blue())
def __setitem__(self, item, value):
self._palette[item].setRed(value[0])
self._palette[item].setGreen(value[1])
self._palette[item].setBlue(value[2])
self._context.setContextProperty('ttpalette', self._palette)
class ParserQML(Parser):
def __init__(self, tt, row, cells, nextrow):
self._row = row
self._cells = cells
self._nextrow = nextrow
super().__init__(tt)
def emitcharacter(self, c):
self._cells[self._cell].setProperty('c', c)
for state, value in self._state.items():
self._cells[self._cell].setProperty(state, value)
self._dh |= self._state['dh']
self._cell += 1
def parse(self):
self._cell = 0
self._dh = False
super().parse()
self._row.setProperty('rowheight', 2 if self._dh else 1)
if self._nextrow:
self._nextrow.setProperty('rowrendered', not (self._row.property('rowrendered') and self._dh))
class Decoder(object):
def __init__(self, widget):
self.widget = widget
self._fonts = [
[
[self.make_font(100), self.make_font(50)],
[self.make_font(200), self.make_font(100)]
],
[
[self.make_font(120), self.make_font(60)],
[self.make_font(240), self.make_font(120)]
]
]
self.widget.rootContext().setContextProperty('ttfonts', self._fonts)
self._palette = Palette(self.widget.rootContext())
qml_file = os.path.join(os.path.dirname(__file__), 'decoder.qml')
self.widget.setSource(QUrl.fromLocalFile(qml_file))
self._rows = [self.widget.rootObject().findChild(QObject, 'rows').itemAt(x) for x in range(25)]
self._cells = [[r.findChild(QObject, 'cols').itemAt(x) for x in range(40)] for r in self._rows]
self._data = np.zeros((25, 40), dtype=np.uint8)
self._parsers = [ParserQML(self._data[x], self._rows[x], self._cells[x], self._rows[x+1] if x < 24 else None) for x in range(25)]
self.zoom = 2
def __setitem__(self, item, value):
self._data[item] = value
if isinstance(item, tuple):
item = item[0]
if isinstance(item, int):
self._parsers[item].parse()
else:
for p in self._parsers[item]:
p.parse()
def __getitem__(self, item):
return self._data[item]
def randomize(self):
self[1:] = np.random.randint(0, 256, size=(24, 40), dtype=np.uint8)
def make_font(self, stretch):
font = QFont('teletext2')
font.setStyleStrategy(QFont.NoSubpixelAntialias)
font.setHintingPreference(QFont.PreferNoHinting)
font.setStretch(stretch)
return font
@property
def palette(self):
return self._palette
@property
def zoom(self):
return self.widget.rootObject().property('zoom')
@zoom.setter
def zoom(self, zoom):
if 0 < zoom < 5:
self._fonts[0][0][0].setPixelSize(zoom * 10)
self._fonts[0][0][1].setPixelSize(zoom * 20)
self._fonts[0][1][0].setPixelSize(zoom * 10)
self._fonts[0][1][1].setPixelSize(zoom * 20)
self._fonts[1][0][0].setPixelSize(zoom * 10)
self._fonts[1][0][1].setPixelSize(zoom * 20)
self._fonts[1][1][0].setPixelSize(zoom * 10)
self._fonts[1][1][1].setPixelSize(zoom * 20)
self.widget.rootContext().setContextProperty('ttfonts', self._fonts)
self.widget.rootObject().setProperty('zoom', zoom)
self.widget.setFixedSize(self.size())
@property
def reveal(self):
return self.widget.rootObject().property('reveal')
@reveal.setter
def reveal(self, reveal):
self.widget.rootObject().setProperty('reveal', reveal)
@property
def crteffect(self):
return self.widget.rootObject().property('crteffect')
@crteffect.setter
def crteffect(self, crteffect):
self.widget.rootObject().setProperty('crteffect', crteffect)
def size(self):
sf = self.widget.rootObject().size()
return QSize(int(sf.width()), int(sf.height()))
def setEffect(self, e):
self._effect = bool(e)
self.widget.rootContext().setContextProperty('tteffect', self._effect)
================================================
FILE: teletext/gui/decoder.qml
================================================
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtGraphicalEffects 1.12
Rectangle {
property int zoom: 2
property int borderSize: 10 * zoom
property bool crteffect: true
property bool flashsrc: true
property bool reveal: false
width: teletext.width + borderSize * 4
height: teletext.height + borderSize * 2
border.width: borderSize
border.color: "black"
color: "black"
Column {
id: teletext
objectName: "teletext"
width: 40 * 8 * zoom
height: 250 * zoom
x: borderSize * 2
y: borderSize
clip: true
Repeater {
objectName: "rows"
model: 25
Row {
property int rowheight: 1
property bool rowrendered: true
Repeater {
objectName: "cols"
model: 40
Item {
property string c: "X"
property int bg: 1
property int fg: 7
property bool dw: false
property bool dh: false
property bool flash: false
property bool mosaic: false
property bool solid: true
property bool boxed: false
property bool conceal: false
property bool rendered: true
height: 10 * zoom
width: 8 * zoom
Rectangle {
height: rowheight * 10 * zoom
width: (dw?2:1) * 8 * zoom
clip: true
visible: rowrendered && rendered
color: ttpalette[bg]
Text {
renderType: Text.NativeRendering
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
color: ttpalette[fg]
text: c
font: ttfonts[(mosaic && solid && text[0] > "\ue000")?1:0][dw?1:0][dh?1:0]
visible: ((!flash) || flashsrc) && (conceal ? reveal : true)
}
}
}
}
}
}
layer.enabled: crteffect && (zoom > 1)
layer.effect: ShaderEffect {
fragmentShader: "
uniform lowp sampler2D source;
uniform lowp float qt_Opacity;
varying highp vec2 qt_TexCoord0;
varying lowp vec3 qt_FragCoord0;
void main() {
lowp vec4 tex = texture2D(source, qt_TexCoord0);
int zoom = " + zoom + ";
int row = int(gl_FragCoord.y) % zoom;
gl_FragColor = (0 < row && (row < 2 || row < (zoom-1))) ? tex : tex*0.6;
}
"
}
}
layer.enabled: crteffect && (zoom > 1)
layer.effect: GaussianBlur {
radius: 0.75 * zoom
}
SequentialAnimation on flashsrc {
loops: -1
running: true
PropertyAction { value: false }
PauseAnimation { duration: 333 }
PropertyAction { value: true }
PauseAnimation { duration: 1000 }
}
}
================================================
FILE: teletext/gui/editor.py
================================================
import os
from teletext.file import FileChunker
from teletext.packet import Packet
try:
from PyQt5 import QtCore, QtGui, QtWidgets, QtQuickWidgets, uic
except ImportError:
print('PyQt5 is not installed. Qt VBI Viewer not available.')
from teletext.gui.decoder import Decoder
from teletext.gui.service import ServiceModel, StdSubpage, ServiceModelLoader
class EditorWindow(QtWidgets.QMainWindow):
def __init__(self):
super(EditorWindow, self).__init__()
ui_file = os.path.join(os.path.dirname(__file__), 'editor.ui')
self.ui = uic.loadUi(ui_file)
self._tt = Decoder(self.ui.DecoderWidget)
self.ui.actionZoom_In.triggered.connect(lambda: setattr(self._tt, 'zoom', self._tt.zoom+1))
self.ui.actionZoom_Out.triggered.connect(lambda: setattr(self._tt, 'zoom', self._tt.zoom-1))
self.ui.action1x.triggered.connect(lambda: setattr(self._tt, 'zoom', 1))
self.ui.action2x.triggered.connect(lambda: setattr(self._tt, 'zoom', 2))
self.ui.action3x.triggered.connect(lambda: setattr(self._tt, 'zoom', 3))
self.ui.action4x.triggered.connect(lambda: setattr(self._tt, 'zoom', 4))
self.ui.actionImport_T42.triggered.connect(self.importt42)
self.ui.actionCRT_Effect.setProperty('checked', self._tt.crteffect)
self.ui.actionCRT_Effect.toggled.connect(lambda x: setattr(self._tt, 'crteffect', x))
self.ui.actionReveal.setProperty('checked', self._tt.reveal)
self.ui.actionReveal.toggled.connect(lambda x: setattr(self._tt, 'reveal', x))
self.ui.ServiceTree.doubleClicked.connect(self.showsubpage)
self.ui.ServiceTree.header().setSortIndicator(0, QtCore.Qt.AscendingOrder)
self.progress = QtWidgets.QProgressBar()
self.progress.setVisible(False)
self.progress.setFixedWidth(200)
self.ui.statusBar().addPermanentWidget(self.progress)
try:
self.importt42('/media/al/Teletext/test.t42')
except FileNotFoundError:
pass
self.ui.show()
def showsubpage(self, index):
item = self.ui.ServiceTree.model().itemFromIndex(index)
if isinstance(item, StdSubpage):
self._tt[1:] = item._subpage.displayable[:]
self._tt[0, :8] = 0x20
self._tt[0, 8:] = item._subpage.header.displayable[:]
def importt42(self, filename=None):
self.ui.actionImport_T42.setEnabled(False)
if not isinstance(filename, str):
filename = QtWidgets.QFileDialog.getOpenFileName(self, "Open Teletext Page", "", "T42 Files (*.t42)")[0]
if filename == '':
return
self.service_thread = ServiceModelLoader(filename)
self.service_thread.total.connect(self.progress.setMaximum)
self.service_thread.update.connect(self.progress.setValue)
self.service_thread.finished.connect(self.importt42done)
self.ui.statusBar().addPermanentWidget(self.progress)
self.service_thread.start()
self.progress.setVisible(True)
def importt42done(self):
model = self.service_thread.model
del self.service_thread
self.ui.ServiceTree.setModel(model)
i = model.invisibleRootItem().child(0).child(0).child(0).index()
self.ui.ServiceTree.scrollTo(i)
self.showsubpage(i)
self.progress.reset()
self.progress.setVisible(False)
self.ui.actionImport_T42.setEnabled(True)
def main():
import sys
app = QtWidgets.QApplication(sys.argv)
window = EditorWindow()
app.exec_()
if __name__ == '__main__':
main()
================================================
FILE: teletext/gui/editor.ui
================================================
MainWindow001056654Teletext Editorfalse0QLayout::SetDefaultConstraint1111QFrame::Panel1trueQt::AlignCenter0085560800background-color: rgb(0, 0, 0);00000QQuickWidget::SizeViewToRootObject00105620FileViewZoomService102222QFrame::PanelQAbstractItemView::NoEditTriggersfalseQAbstractItemView::NoDragDropQt::MoveActionQAbstractItemView::ExtendedSelectionQAbstractItemView::SelectItemstruetruefalseImport T42ImportZoom InCtrl++Zoom OutCtrl+-1x2x3x4xtrueCRT EffecttrueRevealQQuickWidgetQWidgetQtQuickWidgets/QQuickWidget
================================================
FILE: teletext/gui/qthelpers.py
================================================
from PyQt5 import QtWidgets
def build_menu(window, parent_menu, menu_defs):
for name, action, shortcut in menu_defs:
if name is None:
parent_menu.addSeparator()
elif isinstance(action, list):
m = parent_menu.addMenu(name)
build_menu(window, m, action)
else:
a = QtWidgets.QAction(name, window)
if shortcut:
a.setShortcut(shortcut)
if callable(action):
a.triggered.connect(action)
else:
print(f'Warning: menu item {name}: {action} is not callable.')
parent_menu.addAction((a))
================================================
FILE: teletext/gui/service.py
================================================
from PyQt5 import QtCore, QtGui, QtWidgets, QtQuickWidgets
from PyQt5.QtCore import QVariant
from teletext.file import FileChunker
from teletext.packet import Packet
from teletext.service import Service
class StdSubpage(QtGui.QStandardItem):
def __init__(self, subpage, number):
self._subpage = subpage
self._number = number
super().__init__(f'Subpage {self._subpage.addr}')
for s in self._subpage.duplicates:
self.appendRow(StdSubpage(s, self._number))
class StdPage(QtGui.QStandardItem):
def __init__(self, page, number):
self._page = page
self._number = number
super().__init__(f'Page {self._number:02X}')
for n, s in sorted(self._page.subpages.items()):
self.appendRow(StdSubpage(s, n))
class StdMagazine(QtGui.QStandardItem):
def __init__(self, magazine, number):
self._magazine = magazine
self._number = number
super().__init__(f'Magazine {self._number}')
self.setDragEnabled(False)
for n, p in sorted(self._magazine.pages.items()):
self.appendRow(StdPage(p, (0x100*self._number)+n))
class ServiceModel(QtGui.QStandardItemModel):
def __init__(self, service = None):
super().__init__()
self._service = service or Service()
for n, m in sorted(self._service.magazines.items()):
self.invisibleRootItem().appendRow(StdMagazine(m, n))
class ServiceModelLoader(QtCore.QThread):
total = QtCore.pyqtSignal(int)
update = QtCore.pyqtSignal(int)
def __init__(self, filename):
self._filename = filename
super().__init__()
def progress(self, chunks):
for n, d in chunks:
if n&0xfff == 0:
self.update.emit(n)
yield n, d
def run(self):
with open(self._filename, 'rb') as f:
chunks = FileChunker(f, 42)
self.total.emit(len(chunks))
packets = (Packet(data, number) for number, data in self.progress(chunks))
service = Service.from_packets(packets)
self.model = ServiceModel(service)
================================================
FILE: teletext/gui/vbiplot.py
================================================
import sys
import numpy as np
from PyQt5 import QtWidgets
from PyQt5.QtWidgets import QApplication, QPushButton, QMainWindow, QVBoxLayout
import matplotlib.pyplot as plt
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar
from matplotlib.figure import Figure
from itertools import islice
from scipy import signal
from teletext.vbi.line import Line
class Window(QMainWindow):
def __init__(self, chunker, config, parent=None):
super(Window, self).__init__(parent)
self.chunker = chunker
self.config = config
self.chunks = self.chunker(self.config.line_length * np.dtype(self.config.dtype).itemsize, self.config.field_lines, self.config.field_range)
self.canvas = FigureCanvas()
self.button = QPushButton('Next')
self.button.clicked.connect(self.plot)
self.n_lines = 16
w = QtWidgets.QWidget(self)
self.setCentralWidget(w)
layout = QVBoxLayout()
#layout.addWidget(self.toolbar)
layout.addWidget(self.canvas)
layout.addWidget(self.button)
w.setLayout(layout)
self.show()
self.plot()
def plot(self):
fig = Figure()
axs = fig.subplots(self.n_lines, 3, sharex='col', sharey='col')
for n, (o, d) in enumerate(islice(self.chunks, self.n_lines)):
ax = axs[n][0]
ax.set_ylabel(str(o))
line = Line(d)
xaxis_scaled = np.arange(self.config.line_length) * 8 * self.config.teletext_bitrate / self.config.sample_rate
xaxis = np.arange(len(line.resampled))
ax.plot(xaxis_scaled, line.original, color='green' if line.is_teletext else 'red', linewidth=0.5)
if line.start is not None:
ax.plot(line.start, line.resampled[line.start], 'x')
ax.plot(line.start + 128 + 12, line.resampled[line.start + 128 + 12], 'x')
ax = axs[n][1]
widths = np.array([8, 12, 16, 20, 24, 28, 32])
cwtmatr = signal.cwt(line.resampled, signal.morlet2, widths)
ll = np.sum(np.abs(cwtmatr), axis=0)
#ax.plot(ll)
ax.pcolormesh(np.abs(cwtmatr), cmap='viridis', shading='gouraud')
#ax.imshow(cwtmatr, extent=[-1, 1, 31, 1], cmap='PRGn', aspect='auto',
# vmax=abs(cwtmatr).max(), vmin=-abs(cwtmatr).max())
ax = axs[n][2]
h = np.histogram(ll, np.arange(0, np.max(ll), 1))
c = np.cumsum(h[0])/len(ll)
t = np.array([np.argmax(c>m/10) for m in range(1,10)])
print(t)
l = 'green'
if t[-1] - t[0] < 200: # quiet line?
l = 'red'
elif t[1] < 75 and t[2] > 200: # not teletext?
l = 'red'
ax.plot(h[1][:-1], c, color=l, linewidth=0.5)
ax.plot(t, c[t], 'x', color=l)
axs[-1][0].set_xlabel(f'samples, resampled to 8x {self.config.teletext_bitrate} Hz')
self.canvas.figure = fig
x, y = self.width(), self.height()
self.resize(x+1, y+1)
self.resize(x, y)
# refresh canvas
self.canvas.draw()
# close the figure so that we don't create too many figure instances
plt.close(fig)
def vbiplot(chunker, config):
# To prevent random crashes when rerunning the code,
# first check if there is instance of the app before creating another.
app = QApplication.instance()
if app is None:
app = QApplication(sys.argv)
main = Window(chunker, config)
main.show()
app.exec_()
================================================
FILE: teletext/image.py
================================================
import math
import numpy as np
from teletext.parser import Parser
from PIL import Image
from PIL.PcfFontFile import *
class PcfFontFileUnicode(PcfFontFile):
def unicode_glyphs(self):
metrics = self._load_metrics()
bitmaps = self._load_bitmaps(metrics)
# map character code to bitmap index
encoding = {}
fp, format, i16, i32 = self._getformat(PCF_BDF_ENCODINGS)
firstCol, lastCol = i16(fp.read(2)), i16(fp.read(2))
firstRow, lastRow = i16(fp.read(2)), i16(fp.read(2))
i16(fp.read(2)) # default
nencoding = (lastCol - firstCol + 1) * (lastRow - firstRow + 1)
for i in range(nencoding):
encodingOffset = i16(fp.read(2))
if encodingOffset != 0xFFFF:
encoding[i + firstCol] = encodingOffset
glyphs = {}
for ch, ix in encoding.items():
if ix is not None:
x, y, l, r, w, a, d, f = metrics[ix]
glyph = (w, 0), (l, d - y, x + l, d), (0, 0, x, y), bitmaps[ix]
glyphs[ch] = glyph[3]
return glyphs
def load_glyphs(fp):
f = PcfFontFileUnicode(fp)
return f.unicode_glyphs()
class PrinterImage(Parser):
def __init__(self, tt, glyphs, box=False, flash_off=False, colour=True, codepage=0):
self.colour = colour
self.column = 0
self.glyphs = glyphs
self.image = Image.new("P", (12*len(tt), 20), color=8)
self.missing = set()
self.flash_off = flash_off
self.flash_used = False
self.box = box
super().__init__(tt)
def emitcharacter(self, c):
if self._state['boxed'] or not self.box:
if self._state['conceal'] or (self.flash_off and self._state['flash']):
c = ' '
try:
glyph = self.glyphs[ord(c)]
except KeyError:
self.missing.add(c)
else:
data = np.choose(glyph, (self._state['bg'], self._state['fg']))
i = Image.fromarray(data.astype(np.uint8), "P")
i = i.resize((
i.width * (2 if self._state['dw'] else 1),
i.height * (2 if self._state['dh'] else 1),
))
self.image.paste(i, (self.column*12, 0))
self.column += 1
def parse(self):
super().parse()
return self.missing
def subpage_to_image(s, glyphs, background=None, flash_off=False):
img = Image.new("P", (12*40, 25*10), color=8)
missing = set()
img.putpalette([
0, 0, 0, 255,
255, 0, 0, 255,
0, 255, 0, 255,
255, 255, 0, 255,
0, 0, 255, 255,
255, 0, 255, 255,
0, 255, 255, 255,
255, 255, 255, 255,
0, 0, 0, 0,
], "RGBA")
box = s.header.newsflash or s.header.subtitle
flash_used = False
if not s.header.supress_header:
prnt = PrinterImage(s.header.displayable._array, glyphs, flash_off=flash_off)
missing.update(prnt.parse())
img.paste(prnt.image, (12*8, 0))
if np.any(s.header.displayable == 0x08):
flash_used = True
for i in range(0, 24):
# only draw the line if previous line does not contain double height code
if i == 0 or np.all(s.displayable[i - 1, :] != 0x0d):
prnt = PrinterImage(s.displayable[i, :], glyphs, flash_off=flash_off, box=box)
missing.update(prnt.parse())
img.paste(prnt.image, (0, (i+1)*10))
if np.any(s.displayable[i - 1, :] == 0x08):
flash_used = True
img = img.convert("RGBA").resize((img.width, img.height*2))
if background is None:
background = Image.new("RGBA", (720, 576), color=(0, 0, 0, 0 if box else 255))
else:
background = background.convert("RGBA").resize((720, 576))
background.alpha_composite(img, (
(background.width - img.width) // 2,
(background.height - img.height) // 2,
))
background._missing_glyphs = missing
background._flash_used = flash_used
return background
================================================
FILE: teletext/interactive.py
================================================
import curses
import os
import time
import locale
from .file import FileChunker
from .packet import Packet
from .parser import Parser
from .printer import PrinterANSI
def setstyle(self, fg=None, bg=None):
return '\033[' + chr(fg or self.fg) + chr(bg or self.bg) + chr(1 if self.flash else 0 + 2 if self.conceal else 0)
PrinterANSI.setstyle = setstyle
class TerminalTooSmall(Exception):
pass
class ParserNcurses(Parser):
def __init__(self, tt, scr, row):
self._scr = scr
self._row = row
super().__init__(tt)
def emitcharacter(self, c):
colour = Interactive.colours[self._state['fg']] | Interactive.colours[self._state['bg']] << 3
if self._state['conceal']:
colour += 64
self._scr.addstr(self._row, self._pos, c, curses.color_pair(colour+1) | (curses.A_BLINK if self._state['flash'] else 0))
self._pos += 1
def parse(self):
self._pos = 0 if self._row else 8
super().parse()
class Interactive(object):
colours = {0: curses.COLOR_BLACK, 1: curses.COLOR_RED, 2: curses.COLOR_GREEN, 3: curses.COLOR_YELLOW,
4: curses.COLOR_BLUE, 5: curses.COLOR_MAGENTA, 6: curses.COLOR_CYAN, 7: curses.COLOR_WHITE}
def __init__(self, packet_iter, scr, initial_page=0x100):
self.scr = scr
self.packet_iter = packet_iter
if initial_page is None:
self.magazine = 1
self.page = 0
else:
self.magazine = initial_page >> 8
self.page = initial_page & 0xff
self.last_subpage = None
self.last_header = None
self.inputtmp = [None, None, None]
self.inputstate = 0
self.need_clear = False
self.hold = False
self.reveal = False
self.links = [(7, 255), (7, 255), (7, 255), (7, 255)]
y, x = self.scr.getmaxyx()
if x < 41 or y < 25:
raise TerminalTooSmall(x, y)
curses.start_color()
for n in range(64):
curses.init_pair(n + 1, Interactive.colours[n & 0x7], Interactive.colours[n >> 3])
self.set_concealed_pairs()
self.scr.nodelay(1)
curses.curs_set(0)
self.set_input_field('P%d%02x' % (self.magazine, self.page))
def set_concealed_pairs(self, show=False):
for n in range(16):
# workaround for ncurses bug where pairs are only refreshed if their previous
# fg and bg are both not black. one of the temp colours here must be not black
# and one must be different to the actual desired colours.
curses.init_pair(n + 1 + 64, 0, 1)
for n in range(64):
curses.init_pair(n + 1 + 64, Interactive.colours[n & 0x7] if show else Interactive.colours[n >> 3],
Interactive.colours[n >> 3])
def set_input_field(self, str, clr=0):
self.scr.addstr(0, 3, str, curses.color_pair(clr))
def go_page(self, magazine, page):
self.inputstate = 0
self.magazine = magazine
self.page = page
self.set_input_field('P%d%02x' % (self.magazine, self.page))
def addstr(self, packet):
r = packet.mrag.row
if r:
ParserNcurses(packet.displayable[:], self.scr, r)
else:
ParserNcurses(packet.header.displayable[:], self.scr, r)
def do_alnum(self, i):
if self.inputstate == 0:
if i >= 1 and i <= 8:
self.inputtmp[0] = i
self.inputstate = 1
else:
self.inputtmp[self.inputstate] = i
self.inputstate += 1
if self.inputstate != 0:
self.set_input_field(
'P' + ''.join([('%1X' % self.inputtmp[x]) if self.inputtmp[x] is not None else '.' for x in range(3)]),
3 if self.inputstate < 3 else 0)
if self.inputstate == 3:
self.inputstate = 0
self.magazine = self.inputtmp[0]
self.page = (self.inputtmp[1] << 4) | self.inputtmp[2]
self.inputtmp = [None, None, None]
self.last_header = None
self.need_clear = True
def do_hold(self):
self.hold = not self.hold
if self.hold:
self.set_input_field('HOLD', 2)
self.inputstate = 0
self.inputtmp[0] = None
self.inputtmp[1] = None
self.inputtmp[2] = None
else:
self.set_input_field('P%d%02x' % (self.magazine, self.page))
self.need_clear = True
def do_reveal(self):
self.reveal = not self.reveal
self.set_concealed_pairs(self.reveal)
def do_input(self, c):
if c >= ord('0') and c <= ord('9'):
if self.hold:
self.do_hold()
self.do_alnum(c - ord('0'))
elif c >= ord('a') and c <= ord('f'):
if self.hold:
self.do_hold()
self.do_alnum(c + 10 - ord('a'))
elif c == ord('.'):
self.do_hold()
elif c == ord('r'):
self.do_reveal()
elif c == ord('h'):
self.go_page(self.links[0].magazine, self.links[0].page)
elif c == ord('j'):
self.go_page(self.links[1].magazine, self.links[1].page)
elif c == ord('k'):
self.go_page(self.links[2].magazine, self.links[2].page)
elif c == ord('l'):
self.go_page(self.links[3].magazine, self.links[3].page)
elif c == ord('q'):
self.running = False
def handle_one_packet(self):
packet = next(self.packet_iter)
if self.inputstate == 0 and not self.hold:
if packet.mrag.magazine == self.magazine:
if packet.mrag.row == 0:
if packet.header.page == self.page:
if self.need_clear or packet.header.control & 0x1:
self.scr.erase()
self.need_clear = False
self.last_header = packet.header.page
self.addstr(packet)
self.set_input_field('P%d%02X' % (self.magazine, self.page))
elif self.last_header == self.page:
if packet.mrag.row < 25:
self.addstr(packet)
elif packet.mrag.row == 27:
self.links = packet.fastext.links
#print(self.links)
def main(self):
self.running = True
while self.running:
for i in range(32):
self.handle_one_packet()
self.do_input(self.scr.getch())
self.scr.refresh()
time.sleep(0.01)
def main(packets, initial_page):
locale.setlocale(locale.LC_ALL, '')
if os.name == 'nt':
f = open("CON:", 'r')
else:
f = open("/dev/tty", 'r')
os.dup2(f.fileno(), 0)
def main(scr):
Interactive(packets, scr, initial_page=initial_page).main()
try:
curses.wrapper(main)
except TerminalTooSmall as e:
print(f'Your terminal is too small.\nPlease make it at least 41x25.\nCurrent size: {e.args[0]}x{e.args[1]}.')
exit(-1)
================================================
FILE: teletext/mp.py
================================================
import atexit
import itertools
import pickle
import queue
import multiprocessing as mp
import zmq
def denumerate(work, control, tmp_queue):
"""Strips sequence numbers from work_queue items and yields the work."""
poller = zmq.Poller()
poller.register(work, zmq.POLLIN)
poller.register(control, zmq.POLLIN)
while True:
socks = dict(poller.poll())
if socks.get(work) == zmq.POLLIN:
n, item = work.recv_pyobj()
tmp_queue.put((n, len(item)))
yield from item
if socks.get(control) == zmq.POLLIN:
return
def renumerate(iterator, result, tmp_queue):
"""Recombines results with the sequence numbers stored in tmp_queue."""
try:
while True:
r = [next(iterator)]
n, l = tmp_queue.get()
while len(r) < l:
r.append(next(iterator))
result.send_pyobj((n, r))
except StopIteration:
pass
def worker(work_port, result_port, control_port, status_port, function, args, kwargs):
"""Subprocess main. Runs a generator function on items from a pipe."""
tmp_queue = queue.Queue()
ctx = zmq.Context()
work = ctx.socket(zmq.PULL)
work.set_hwm(10)
result = ctx.socket(zmq.PUSH)
status = ctx.socket(zmq.PUSH)
control = ctx.socket(zmq.SUB)
try:
work.connect(f'tcp://localhost:{work_port}')
result.connect(f'tcp://localhost:{result_port}')
status.connect(f"tcp://localhost:{status_port}")
control.connect(f"tcp://localhost:{control_port}")
control.setsockopt(zmq.SUBSCRIBE, b"")
status.send_string('CON')
renumerate(function(denumerate(work, control, tmp_queue), *args, **kwargs), result, tmp_queue)
except KeyboardInterrupt:
pass
finally:
status.send_string('DED')
class _PureGeneratorPoolMP(object):
def __init__(self, function, processes=1, *args, **kwargs):
self._processes = processes
self._function = function
self._args = args
self._kwargs = kwargs
self._procs = []
# Similar to how, on Linux, putting an unpickleable object on a Queue
# causes an uncatchable exception, passing unpickleable objects to
# ctx.Process does the same thing on Windows. So we must check that
# everything can be pickled before attempting to use it. Luckily this
# is only done once.
pickle.dumps(self._function)
pickle.dumps(self._args)
pickle.dumps(self._kwargs)
def __enter__(self):
mp_ctx = mp.get_context('spawn')
self._ctx = zmq.Context()
self._work = self._ctx.socket(zmq.PUSH)
work_port = self._work.bind_to_random_port('tcp://*')
self._result = self._ctx.socket(zmq.PULL)
result_port = self._result.bind_to_random_port('tcp://*')
self._status = self._ctx.socket(zmq.PULL)
status_port = self._status.bind_to_random_port('tcp://*')
self._control = self._ctx.socket(zmq.PUB)
control_port = self._control.bind_to_random_port('tcp://*')
try:
for id in range(self._processes):
p = mp_ctx.Process(target=worker, args=(
work_port, result_port, control_port, status_port,
self._function, self._args, self._kwargs
))
self._procs.append(p)
for p in self._procs:
p.start()
atexit.register(self.__exit__)
for p in self._procs:
s = self._status.recv_string()
if s == 'DED':
raise ChildProcessError("Worker failed to start.")
except (KeyboardInterrupt, ChildProcessError):
self._control.send_string("DIE")
raise
return self
def apply(self, iterable):
try:
chunksize = min(64, 1+(len(iterable)//len(self._procs)))
except TypeError:
chunksize = 64
it = iter(iterable)
iterable = enumerate(iter(lambda: list(itertools.islice(it, chunksize)), []))
received = {}
sent_count = 0
received_count = 0
done = False
poller = zmq.Poller()
poller.register(self._work, zmq.POLLOUT)
poller.register(self._status, zmq.POLLIN)
poller.register(self._result, zmq.POLLIN)
while True:
socks = dict(poller.poll())
if socks.get(self._status) == zmq.POLLIN:
raise ChildProcessError('Worker exited unexpectedly.')
if socks.get(self._result) == zmq.POLLIN:
n, item = self._result.recv_pyobj()
received[n] = item
while received_count in received:
yield from received[received_count]
del received[received_count]
received_count += 1
if not done and sent_count - received_count < self._processes * 3:
poller.register(self._work, zmq.POLLOUT)
if done and sent_count == received_count:
return
if socks.get(self._work) == zmq.POLLOUT:
try:
self._work.send_pyobj(next(iterable))
sent_count += 1
if sent_count - received_count > self._processes * 4:
poller.unregister(self._work)
except StopIteration:
done = True
if sent_count == 0:
# we didn't send any work, so we will wait forever unless we exit now
return
def __exit__(self, *args):
self._control.send_string("DIE")
for proc in self._procs:
proc.join()
atexit.unregister(self.__exit__)
class _PureGeneratorPoolSingle(object):
"""
An implementation of PureGeneratorPool that doesn't use multiple processes.
"""
def __init__(self, function, *args, **kwargs):
self._function = function
self._args = args
self._kwargs = kwargs
self._work_queue = queue.Queue()
self._proc = self._function(self._work, *args, **kwargs)
@property
def _work(self):
while True:
try:
yield self._work_queue.get(block=False)
except queue.Empty:
return
def __enter__(self):
return self
def apply(self, iterable):
for item in iterable:
self._work_queue.put(item)
yield next(self._proc)
def __exit__(self, *args):
try:
next(self._proc)
except StopIteration:
pass
def PureGeneratorPool(function, processes, *args, **kwargs):
"""
Implements a parallel processing pool similar to multiprocessing.Pool. However,
Pool.map(f, i) calls f on every item in i individually. f is expected to return
the result. PureGeneratorPool.apply(f, i) calls f exactly once for each process
it starts, and then delivers an iterator containing work items. f is expected
to yield results. In practice, this means you can pass large objects to f and
they will only be pickled once rather than for every item in i. It also allows
you to do one-time setup at the beginning of f.
f must be a "pure generator". This means it must yield exactly one result for
each item in the iterator, and that result must only depend on the current
item being processed. It must not have any mutable state which affects the
output. For example, any function of the form:
itertools.partial(map, f)
is a pure generator if f is pure.
And further:
def gen(g, f, it):
g()
yield from f(it)
is a pure generator if f is a pure generator, regardless of whether or not g
is pure.
apply() preserves the ordering of items in the input iterator.
"""
if processes > 1:
return _PureGeneratorPoolMP(function, processes, *args, **kwargs)
else:
return _PureGeneratorPoolSingle(function, *args, **kwargs)
def itermap(function, iterable, processes=1, *args, **kwargs):
"""One-shot function to make a PureGeneratorPool and apply it."""
with PureGeneratorPool(function, processes, *args, **kwargs) as pool:
yield from pool.apply(iterable)
if __name__ in ['__main__', '__mp_main__']:
def f(iterator, *args, **kwargs):
# f first creates an unpickable, unsharable object. It must be done
# exactly once per process.
print('This line MUST be printed exactly once by each process.', args, kwargs)
for item in iterator:
#time.sleep(1)
yield item
if __name__ == '__main__':
import click
from tqdm import tqdm
@click.command()
@click.option('-j', '--jobs', type=int, default=1000000)
@click.option('-t', '--threads', type=int, default=2)
@click.option('-v', '--verbose', is_flag=True)
def main(jobs, threads, verbose):
for result in itermap(f, iter(tqdm(range(jobs))), processes=threads, a=2, b=3):
if(verbose):
print(result, end=' ')
print('')
main()
================================================
FILE: teletext/packet.py
================================================
from .elements import *
class Packet(Element):
def __init__(self, array=None, number=None, original=None):
super().__init__((42, ), array)
self._number = number
self._original = original
def __getitem__(self, item):
return self._array[item]
def __setitem__(self, item, value):
self._array[item] = value
def is_padding(self):
return not np.any(self._array)
@property
def number(self):
return self._number
@property
def type(self):
row = self.mrag.row
if row == 0:
return 'header'
elif row < 26:
return 'display'
elif row == 27:
return 'fastext'
elif row == 30 and self.mrag.magazine == 8:
return 'broadcast'
elif row in [26, 28]:
return 'page enhancement'
elif row == 29:
return 'magazine enhancement'
elif row in [30, 31] and self.mrag.magazine == 4:
return 'celp'
elif row in [30, 31]:
return 'independent data'
else:
return 'unknown'
@property
def mrag(self):
return Mrag(self._array[:2])
@property
def dc(self):
return DesignationCode((1,), self._array[2:3])
@property
def header(self):
return Header(self._array[2:])
@property
def displayable(self):
return Displayable((40,), self._array[2:])
@property
def fastext(self):
return Fastext(self._array[2:], self.mrag)
@property
def triplets(self):
return Triplets(self._array[2:], self.mrag)
@property
def broadcast(self):
return BroadcastData(self._array[2:], self.mrag)
@property
def celp(self):
return Celp(self._array[2:], self.mrag)
def to_ansi(self, colour=True):
t = self.type
if t == 'header':
return f' P{self.mrag.magazine}{self.header.to_ansi(colour)}'
elif t == 'display':
return self.displayable.to_ansi(colour)
elif t == 'page enhancement':
return self.triplets.to_ansi(colour)
elif t == 'fastext':
return self.fastext.to_ansi(colour)
elif t == 'broadcast':
return self.broadcast.to_ansi(colour)
elif t == 'celp':
return self.celp.to_ansi(colour)
elif t.endswith('enhancement'):
return f'{t} DC={self.dc.dc}'
else:
return f'{t}'
def to_bytes_no_parity(self):
t = self.type
if t == 'header':
return self.header.displayable.bytes_no_parity
elif t == 'display':
return self.displayable.bytes_no_parity
elif t == 'broadcast':
return self.broadcast.displayable.bytes_no_parity
else:
return b''
def to_binary(self):
b = np.unpackbits(self._array[::-1])[::-1]
x = b[0::2] | (b[1::2]<<1)
return ''.join([' ', chr(0x258C), chr(0x2590), chr(0x2588)][n] for n in x)
def to_bytes(self):
return self._array.tobytes()
@property
def ansi(self):
return self.to_ansi(colour=True).encode('utf8') + b'\n'
@property
def text(self):
return self.to_ansi(colour=False).encode('utf8') + b'\n'
@property
def bar(self):
return self.to_binary().encode('utf8') + b'\n'
@property
def hex(self):
return self._array.tobytes().hex(' ').encode('utf8') + b'\n'
@property
def debug(self):
if self.number is None:
return f'None {self.mrag.magazine} {self.mrag.row:2d} {self.to_ansi(colour=True)} errors: {np.sum(self.errors)}\n'.encode('utf8')
else:
return f'{self.number:8d} {self.mrag.magazine} {self.mrag.row:2d} {self.to_ansi(colour=True)} errors: {np.sum(self.errors)}\n'.encode('utf8')
@property
def vbi(self):
if self._original is None:
raise Exception('Original VBI data is not available. Probably we are not deconvolving.')
return self._original
@property
def errors(self):
e = np.zeros_like(self._array)
e[:2] = self.mrag.errors
t = self.type
if t == 'header':
e[2:] = self.header.errors
elif t == 'display':
e[2:] = self.displayable.errors
elif t == 'fastext':
e[2:] = self.fastext.errors
elif t == 'broadcast':
e[2:] = self.broadcast.errors
elif t == 'celp':
e[2:] = self.celp.errors
return e
================================================
FILE: teletext/parser.py
================================================
from . import charset
_unicode13 = False
class Parser(object):
"Abstract base class for parsers"
def __init__(self, tt, localcodepage=None, codepage=0):
self.tt = tt
self._state = {}
self.codepage = codepage
self.localcodepage = localcodepage
self.parse()
def reset(self):
self._state['fg'] = 7
self._state['bg'] = 0
self._state['dw'] = False
self._state['dh'] = False
self._state['mosaic'] = False
self._state['solid'] = True
self._state['flash'] = False
self._state['conceal'] = False
self._state['boxed'] = False
self._state['rendered'] = True
self._heldmosaic = ' '
self._heldsolid = True
self._held = False
self._esc = False
#self._codepage = 0 # not implemented
def setstate(self, **kwargs):
any = False
for state, value in kwargs.items():
if value != self._state[state]:
self._state[state] = value
any = True
if state in ['dw', 'dh']:
self._heldmosaic = ' '
getattr(self, state+'Changed', lambda: None)()
if any:
getattr(self, 'stateChanged', lambda: None)()
def ttchar(self, c):
if self._state['mosaic'] and c not in range(0x40, 0x60):
if _unicode13:
return charset.g1[c]
else:
return chr(int(c)+0xee00) if self._state['solid'] else chr(int(c)+0xede0)
else:
if not self.localcodepage:
return charset.g0["default"][c]
else:
if not self._esc and self.codepage:
return charset.g0[self.localcodepage][c]
else:
return charset.g0["default"][c]
def _emitcharacter(self, c):
getattr(self, 'emitcharacter', lambda x: None)(c)
if self._state['dw']:
self._state['rendered'] = not self._state['rendered']
else:
self._state['rendered'] = True
def emitcode(self):
if self._held:
tmp = self._state['solid']
self._state['solid'] = self._heldsolid
self._emitcharacter(self._heldmosaic)
self._state['solid'] = tmp
else:
self._emitcharacter(' ')
def setat(self, **kwargs):
self.setstate(**kwargs)
self.emitcode()
def setafter(self, **kwargs):
self.emitcode()
self.setstate(**kwargs)
def parsebyte(self, b, prev):
h, l = int(b&0xf0), int(b&0x0f)
if h == 0x0:
if l < 8:
self.setafter(fg=l, mosaic=False, conceal=False)
self._heldmosaic = ' '
elif l == 0x8: # flashing
self.setafter(flash=True)
elif l == 0x9: # steady
self.setat(flash=False)
elif l == 0xa:
if prev == 0xa: # end box - set at because we're triggering on the second one
self.setat(boxed=False)
else:
self.emitcode()
elif l == 0xb:
if prev == 0xb: # start box - set at because we're triggering on the second one
self.setat(boxed=True)
else:
self.emitcode()
else: # sizes
dh, dw = bool(l&1), bool(l&2)
if dh or dw:
self.setafter(dh=dh, dw=dw)
else:
self.setat(dh=dh, dw=dw)
elif h == 0x10:
if l < 8:
self.setafter(fg=l, mosaic=True, conceal=False)
elif l == 0x8: # conceal
self.setat(conceal=True)
elif l == 0x9: # contiguous mosaic
self.setat(solid=True)
elif l == 0xa: # separated mosaic
self.setat(solid=False)
elif l == 0xb: # esc/switch
self.emitcode()
self._esc = not self._esc
elif l == 0xc: # black background
self.setat(bg = 0)
elif l == 0xd: # new background
self.setat(bg = self._state['fg'])
elif l == 0xe: # hold mosaic
self._held = True
self.emitcode()
elif l == 0xf: # release mosaic
self.emitcode()
self._held = False
else:
c = self.ttchar(b)
if self._state['mosaic'] and (b & 0x20):
self._heldmosaic = c
self._heldsolid = self._state['solid']
self._emitcharacter(c)
def parse(self):
self.reset()
prev = None
for c in self.tt&0x7f:
self.parsebyte(c, prev)
prev = c
================================================
FILE: teletext/pipeline.py
================================================
from collections import defaultdict
from statistics import mode as pymode
import numpy as np
from scipy.stats.mstats import mode
from tqdm import tqdm
from .subpage import Subpage
from .packet import Packet
def check_buffer(mb, pages, subpages, min_rows=0):
if (len(mb) > min_rows) and mb[0].type == 'header':
page = mb[0].header.page | (mb[0].mrag.magazine * 0x100)
if page in pages or (page & 0x7ff) in pages:
if mb[0].header.subpage in subpages:
yield sorted(mb, key=lambda p: p.mrag.row)
def packet_squash(packets):
return Packet(mode(np.stack([p._array for p in packets]), axis=0)[0][0].astype(np.uint8))
def bsdp_squash_format1(packets):
date = pymode([p.broadcast.format1.date for p in packets])
hour = min(pymode([p.broadcast.format1.hour for p in packets]), 99)
minute = min(pymode([p.broadcast.format1.minute for p in packets]), 99)
second = min(pymode([p.broadcast.format1.second for p in packets]), 99)
return f'{date} {hour:02d}:{minute:02d}:{second:02d}'
def bsdp_squash_format2(packets):
day = min(pymode([p.broadcast.format2.day for p in packets]), 99)
month = min(pymode([p.broadcast.format2.month for p in packets]), 99)
hour = min(pymode([p.broadcast.format1.hour for p in packets]), 99)
minute = min(pymode([p.broadcast.format1.minute for p in packets]), 99)
return f'{month:02d}-{day:02d} {hour:02d}:{minute:02d}'
def paginate(packets, pages=range(0x900), subpages=range(0x3f80), drop_empty=False):
"""Yields packet lists containing contiguous rows."""
magbuffers = [[],[],[],[],[],[],[],[]]
for packet in packets:
mag = packet.mrag.magazine & 0x7
if packet.type == 'header':
yield from check_buffer(magbuffers[mag], pages, subpages, 1 if drop_empty else 0)
magbuffers[mag] = []
magbuffers[mag].append(packet)
for mb in magbuffers:
yield from check_buffer(mb, pages, subpages, 1 if drop_empty else 0)
def subpage_group(packet_lists, threshold, ignore_empty):
"""Group similar subpages."""
spdict = defaultdict(list)
for pl in packet_lists:
if len(pl) > 1:
subpage = Subpage.from_packets(pl, ignore_empty=ignore_empty)
group = spdict[(subpage.mrag.magazine, subpage.header.page, subpage.header.subpage)]
for op in group:
if threshold == -1:
op.append(subpage)
break
d = subpage.diff(op[0])
if d < threshold:
op.append(subpage)
break
else:
group.append([subpage])
groups = []
for group in spdict.values():
groups.extend(group)
return groups
def subpage_squash(packet_lists, threshold=-1, min_duplicates=3, ignore_empty=False):
"""Yields squashed subpages."""
for splist in tqdm(subpage_group(packet_lists, threshold, ignore_empty), unit=' Groups'):
if len(splist) >= min_duplicates:
numbers = mode(np.stack([np.clip(sp.numbers, -100, -1) for sp in splist]), axis=0)[0][0].astype(np.int64)
s = Subpage(numbers=numbers)
for row in range(29):
if row in [26, 27, 28]:
for dc in range(16):
if s.has_packet(row, dc):
packets = [sp.packet(row, dc) for sp in splist if sp.has_packet(row, dc)]
arr = np.stack([p[3:] for p in packets])
s.packet(row, dc)[:3] = packets[0][:3]
if row == 27:
s.packet(row, dc)[3:] = mode(arr, axis=0)[0][0].astype(np.uint8)
else:
t = arr.astype(np.uint32)
t = t[:, 0::3] | (t[:, 1::3] << 8) | (t[:, 2::3] << 16)
result = mode(t, axis=0)[0][0].astype(np.uint32)
s.packet(row, dc)[3::3] = result & 0xff
s.packet(row, dc)[4::3] = (result >> 8) & 0xff
s.packet(row, dc)[5::3] = (result >> 16) & 0xff
else:
if s.has_packet(row):
packets = [sp.packet(row) for sp in splist if sp.has_packet(row)]
arr = np.stack([p[2:] for p in packets])
s.packet(row)[:2] = packets[0][:2]
s.packet(row)[2:] = mode(arr, axis=0)[0][0].astype(np.uint8)
yield s
def to_file(packets, f, format):
"""Write packets to f as format."""
if format == 'auto':
format = 'debug' if f.isatty() else 'bytes'
if f.isatty():
for p in packets:
with tqdm.external_write_mode():
f.write(getattr(p, format))
yield p
else:
for p in packets:
f.write(getattr(p, format))
yield p
================================================
FILE: teletext/printer.py
================================================
import re
from .parser import Parser
class PrinterANSI(Parser):
def __init__(self, tt, colour=True, codepage=0):
self.colour = colour
super().__init__(tt)
def fgChanged(self):
if self.colour:
self._results.append('\033[3{fg}m'.format(**self._state))
def bgChanged(self):
if self.colour:
self._results.append('\033[4{bg}m'.format(**self._state))
def emitcharacter(self, c):
self._results.append(c)
def parse(self):
self._results = []
if self.colour:
self._results.append('\033[37m\033[40m')
super().parse()
if self.colour:
self._results.append('\033[0m')
def __str__(self):
return ''.join(self._results)
class PrinterHTML(Parser):
def __init__(self, tt, fastext=None, pages_set=range(0x100), localcodepage=None, codepage=0):
self.flinkopen = False
self.fastext = fastext
self.pages_set = pages_set
# anchor for header links so we can bookmark a subpage
self.anchor = ""
super().__init__(tt, localcodepage, codepage)
def ttchar(self, c):
# Use the unicode characters produced by the base parser
# but escape < and > so as not to break the HTML.
c = Parser.ttchar(self, c)
if c == ord('<'):
return '<'
elif c == ord('>'):
return '>'
else:
return c
def stateChanged(self):
link = ''
linkclose = ''
if self.fastext:
if self.flinkopen:
linkclose = ''
self.flinkopen = False
fg = self._state['fg']
if fg in [1,2,3,6] and self.fastext[[1,2,3,6].index(fg)] in self.pages_set:
link = '' % self.fastext[[1,2,3,6].index(fg)]
self.flinkopen = True
self._results.extend([
linkclose, '',
'', link
])
def emitcharacter(self, c):
self._results.append(c)
def linkify(self, html):
e = '([^0-9])([0-9]{3})([^0-9]|$)'
def repl(match):
if match.group(2) in self.pages_set:
return '%s%s%s' % (match.group(1), match.group(2), self.anchor, match.group(2), match.group(3))
else:
return '%s%s%s' % (match.group(1), match.group(2), match.group(3))
p = re.compile(e)
return p.sub(repl, html)
def parse(self):
self._results = ['']
super().parse()
self._results.append('')
if self.flinkopen:
self._results.append('')
self._string = ''.join(self._results)
if self.fastext is None:
self._string = self.linkify(self._string)
def __str__(self):
return self._string
================================================
FILE: teletext/service.py
================================================
import datetime
import os
import textwrap
from collections import defaultdict
from tqdm import tqdm
from .subpage import Subpage
from .file import FileChunker
from .packet import Packet
from . import pipeline
class Page(object):
def __init__(self):
self.subpages = {}
self._iter = self._gen()
def _gen(self):
while True:
if len(self.subpages) > 0:
yield from sorted(self.subpages.items())
yield 0x3f7f, None
def __iter__(self):
return self
def __next__(self):
return next(self._iter)
class Magazine(object):
def __init__(self, title=None):
self.title = title or "Unnamed "
self.pages = defaultdict(Page)
self._iter = self._gen()
def _gen(self):
while True:
for pageno, page in sorted(self.pages.items()):
spno, subpage = next(page)
if subpage is None:
p = Packet()
p.mrag.row = 0
p.header.page = 0xff
p.header.subpage = spno
yield p
else:
subpage.header.page = pageno
subpage.header.subpage = spno
yield from subpage.packets
def __iter__(self):
return self
def __next__(self):
return next(self._iter)
class Service(object):
def __init__(self, replace_headers=False, title=None):
self.magazines = defaultdict(lambda: Magazine(title=title))
self.priorities = [1,1,1,1,1,1,1,1]
self.replace_headers = replace_headers
self._iter = self._gen()
def header(self, title, mag, page):
t = datetime.datetime.now()
return '%-9s%1d%02x' % (title, mag, page) + t.strftime(" %a %d %b\x03%H:%M/%S")
def insert_page(self, page):
self.magazines[page.mrag.magazine].pages[page.header.page].subpages[page.header.subpage] = page
def _gen(self):
while True:
for n,m in sorted(self.magazines.items()):
for count in range(self.priorities[n&0x7]):
packet = next(m)
packet.mrag.magazine = n
if packet.type == 'header':
packet = Packet(packet._array)
packet.header.control &= 0x77f # clear magazine serial
if self.replace_headers:
packet.header.displayable.place_string(self.header(m.title, n, packet.header.page))
yield packet
def __iter__(self):
return self
def __next__(self):
return next(self._iter)
def packets(self, n):
for i in range(n):
yield next(self)
@property
def all_subpages(self):
for km, m in sorted(self.magazines.items()):
for kp, p in sorted(m.pages.items()):
for ks, s in sorted(p.subpages.items()):
yield s
@property
def pages_set(self):
return set(f'{m}{p:02x}' for m, mag in self.magazines.items() for p, _ in mag.pages.items())
@classmethod
def from_packets(cls, packets, replace_headers=False, title=None):
svc = cls(replace_headers=replace_headers, title=title)
subpages = (Subpage.from_packets(pl) for pl in pipeline.paginate(packets, drop_empty=True))
for s in subpages:
page = svc.magazines[s.mrag.magazine].pages[s.header.page]
if s.header.subpage in page.subpages:
page.subpages[s.header.subpage].duplicates.append(s)
else:
page.subpages[s.header.subpage] = s
return svc
@classmethod
def from_file(cls, f):
chunks = FileChunker(f, 42)
packets = (Packet(data, number) for number, data in chunks)
return cls.from_packets(packets)
def to_html(self, outdir, template=None, localcodepage=None):
pages_set = self.pages_set
if template is None:
template = textwrap.dedent("""\
Page {page}
{body}
""")
for magazineno, magazine in tqdm(self.magazines.items(), desc='Magazines', unit='M'):
for pageno, page in tqdm(magazine.pages.items(), desc='Pages', unit='P'):
pagestr = f'{magazineno}{pageno:02x}'
outfile = open(os.path.join(outdir, f'{pagestr}.html'), 'w', encoding='utf-8')
body = '\n'.join(
subpage.to_html(pages_set, localcodepage) for n, subpage in sorted(page.subpages.items())
)
outfile.write(template.format(page=pagestr, body=body))
================================================
FILE: teletext/servicedir.py
================================================
import pathlib
from watchdog.events import FileModifiedEvent, FileDeletedEvent
from watchdog.observers import Observer
from .service import Service
from .subpage import Subpage
class ServiceDir(Service):
"""
Implements a service backed by a directory of t42 files.
The files should be organized by page number with one subpage
per file, like this: 100/0000.t42
Whenever a file is modified it will be reloaded into the
service for broadcast in the next loop of the magazine.
"""
def __init__(self, directory, replace_headers, title):
super().__init__(replace_headers=replace_headers, title=title)
self._dir = directory
def file_changed(self, f, deleted=False):
try:
m = int(f.parent.name[0])
p = int(f.parent.name[1:], 16)
s = int(f.stem, 16)
except ValueError:
pass
else:
if deleted:
del self.magazines[m].pages[p].subpages[s]
else:
self.magazines[m].pages[p].subpages[s] = Subpage.from_file(f.open('rb'))
def __enter__(self):
self.observer = Observer()
self.observer.schedule(self, self._dir, recursive=True)
self.observer.start()
# perform initial scan of the pages
path = pathlib.Path(self._dir)
for f in path.rglob("*"):
if f.is_file():
self.file_changed(f)
return self
def __exit__(self, *args, **kwargs):
self.observer.stop()
self.observer.join()
def dispatch(self, evt):
f = pathlib.Path(evt.src_path)
if isinstance(evt, FileModifiedEvent):
self.file_changed(f)
elif isinstance(evt, FileDeletedEvent):
self.file_changed(f, deleted=True)
================================================
FILE: teletext/sigint.py
================================================
import signal
class SigIntDefer(object):
"""
SigIntDefer is a context manager which catches SIGINT (aka KeyboardInterrupt)
and allows the code to check if it has happened or not, and then exit at an
appropriate time, instead of in the middle of doing something important.
Example: the goal here is to make sure that if we print "hello", we always
print "goodbye", even if a KeyboardInterrupt happens. If a KeyboardInterrupt
did happen, SigIntDefer will re-fire it upon exiting the context.
import time
def loop():
with SigIntDefer() as sigint:
while True:
if sigint.fired:
return
print("hello")
time.sleep(0.5)
print("goodbye")
loop()
"""
def __init__(self, times=1):
self._times = 1
self._fired = None
def __enter__(self):
self._old_handler = signal.getsignal(signal.SIGINT)
signal.signal(signal.SIGINT, self.handler)
return self
def handler(self, f, n):
self._fired = (f, n)
self._times -= 1
if self._times == 0:
signal.signal(signal.SIGINT, self._old_handler)
@property
def fired(self):
return self._fired is not None
def __exit__(self, *args, **kwargs):
signal.signal(signal.SIGINT, self._old_handler)
if self._fired:
self._old_handler(*self._fired)
================================================
FILE: teletext/spellcheck.py
================================================
import itertools
import enchant
from .coding import parity_encode
class SpellChecker(object):
common_errors = set(itertools.chain.from_iterable(
itertools.permutations(s, 2) for s in (
'ab', 'bd', 'ce', 'dh', 'ef', 'ei', 'ej', 'er', 'fj', 'ij', 'jl', 'jr', 'jt',
'km', 'ks', 'ku', 'lt', 'mn', 'qr', 'rt', 'tx', 'uv', 'uy', 'uz', 'vz', 'yz',
)
))
def __init__(self, language='en_GB'):
self.dictionary = enchant.Dict(language)
def check_pair(self, x, y):
if x == y or (x, y) in self.common_errors:
return 0
return 1
def weighted_hamming(self, a, b):
return sum(self.check_pair(x, y) for x,y in zip(a, b))
def case_match(self, word, src):
return ''.join(c.lower() if d.islower() else c.upper() for c, d in zip(word, src))
def suggest(self, word):
if len(word) > 1:
lcword = word.lower()
if not self.dictionary.check(lcword):
for suggestion in self.dictionary.suggest(lcword):
if len(suggestion) == len(lcword) and self.weighted_hamming(suggestion.lower(), lcword) == 0:
return self.case_match(suggestion, word)
return word
def spellcheck(self, displayable):
words = ''.join(c if c.isalpha() else ' ' for c in displayable.to_ansi(colour=False)).split(' ')
words = [self.suggest(w) for w in words]
line = ' '.join(words).encode('ascii')
for n, b in enumerate(line):
if b != ord(b' '):
displayable[n] = parity_encode(b)
def spellcheck_packets(packets, language='en_GB'):
sc = SpellChecker(language)
for p in packets:
t = p.type
if t == 'display':
sc.spellcheck(p.displayable)
elif t == 'header':
sc.spellcheck(p.header.displayable)
yield p
================================================
FILE: teletext/stats.py
================================================
import numpy as np
class Histogram(object):
bars = ' ▁▂▃▄▅▆▇█'
label = 'H'
bins = range(2)
def __init__(self, shape=(1000, ), fill=255, dtype=np.uint8):
self._data = np.full(shape, fill_value=fill, dtype=dtype)
self._pos = 0
def insert(self, value):
self._data[self._pos] = value
self._pos += 1
self._pos %= self._data.shape[0]
@property
def histogram(self):
h,_ = np.histogram(self._data, bins=self.bins)
return h
@property
def render(self):
h = self.histogram
m = max(1, np.max(h)) # no div by zero
if m == 0:
return (' ' * len(self.bins))
else:
h2 = np.ceil(h * ((len(self.bars) - 1) / m)).astype(np.uint8)
return ''.join(self.bars[n] for n in h2)
def __str__(self):
return f', {self.label}:|{self.render}|'
class MagHistogram(Histogram):
label = 'M'
bins = range(1, 9)
def __init__(self, packets, size=1000):
super().__init__((size, ))
self._packets = packets
def __iter__(self):
for p in self._packets:
self.insert(p.mrag.magazine)
yield p
class RowHistogram(MagHistogram):
label = 'R'
bins = range(33)
def __iter__(self):
for p in self._packets:
self.insert(p.mrag.row)
yield p
class Rejects(Histogram):
label = 'R'
bins = range(3)
def __init__(self, lines, size=1000):
super().__init__((size, ))
self._lines = lines
def __iter__(self):
for l in self._lines:
self.insert(l == 'rejected')
yield l
def __str__(self):
h = self.histogram
total = max(1, np.sum(h))
return f', {self.label}:{100*h[1]/total:.0f}%'
class ErrorHistogram(Histogram):
label = 'E'
def __init__(self, packets, size=100):
super().__init__((size, 6), fill=0, dtype=np.uint32)
self._packets = packets
def __iter__(self):
for p in self._packets:
self.insert(np.sum(p.vector_gain_errors.reshape(6, -1), axis=1))
yield p
def __str__(self):
bins = np.sum(self._data, axis=0)
bins = np.ceil(bins * ((len(self.bars) - 1) * 2 / self._data.shape[0])).astype(np.uint8)
bins = np.clip(bins, 0, len(self.bars)-1)
return f', {self.label}: |{"".join(self.bars[n] for n in bins)}|'
class StatsList(list):
def __str__(self):
return ''.join(str(x) for x in self)
================================================
FILE: teletext/subpage.py
================================================
import base64
import itertools
import numpy as np
from .coding import crc
from .packet import Packet
from .elements import Element, Displayable
from .printer import PrinterHTML
from .file import FileChunker
class Subpage(Element):
def __init__(self, array=None, numbers=None, prefill=False, magazine=1):
super().__init__((26 + (3*16), 42), array)
if numbers is None:
self._numbers = np.full((26 + (3*16),), fill_value=-100, dtype=np.int64)
else:
self._numbers = numbers
if prefill:
for i in range(0, 25):
self.init_packet(i, 0, magazine)
self.header.displayable[:] = 0x20
self.header.subpage = 0
self.header.control = 1<<0 # erase
self.displayable[:] = 0x20
self.duplicates = []
def diff(self, other):
"""Try to determine if two subpages are the same."""
diff = np.sum(self._array != other._array, axis=1)
rows = (self._numbers != -100) & (other._numbers != -100)
return np.sum(diff * rows)
@property
def numbers(self):
return self._numbers[:]
def _slot(self, row, dc):
if row < 26:
return row
else:
return ((row-26)*16)+26+dc
def has_packet(self, row, dc=0):
return self._numbers[self._slot(row, dc)] > -100
def init_packet(self, row, dc=0, magazine=1):
self.packet(row, dc).mrag.row = row
self.packet(row, dc).mrag.magazine = magazine
self._numbers[self._slot(row, dc)] = -1
def packet(self, row, dc=0):
try:
return Packet(self._array[self._slot(row, dc), :])
except IndexError:
print(row, dc)
raise
@property
def mrag(self):
return self.packet(0).mrag
@property
def header(self):
return self.packet(0).header
@property
def codepage(self):
return self.packet(0).header.codepage
@property
def fastext(self):
return self.packet(27, 0).fastext
@property
def displayable(self):
return Displayable((24, 40), self._array[1:25,2:])
@property
def checksum(self):
'''Calculates the actual checksum of the subpage.'''
c = 0
if self.has_packet(0):
for b in self.header.displayable[:24]:
c = crc(b, c)
else:
for n in range(24):
c = crc(0x20, c)
for r in range(1, 26):
if self.has_packet(r):
for b in self.packet(r).displayable:
c = crc(b, c)
else:
for n in range(40):
c = crc(0x20, c)
return c
@property
def addr(self):
return f'{self.mrag.magazine}{self.header.page:02X}:{self.header.subpage:04X}'
@classmethod
def from_packets(cls, packets, ignore_empty=False):
s = cls()
for p in packets:
row = p.mrag.row
if row >= 29:
continue
dc = 0 if row < 26 else p.dc.dc
i = s._slot(row, dc)
if ignore_empty and s._numbers[i] > -100:
# we've already seen this packet
# if the new one is closer to all spaces than the old one, skip it
if np.sum(s._array[i, :] == 0x80) < np.sum(p[:] == 0x80):
continue
s._array[i, :] = p[:]
s._numbers[i] = -1 if p.number is None else p.number
return s
@classmethod
def from_url(cls, url):
s = cls(prefill=True)
parts = url.split(':')
Element((25, 40), s._array[0:25, 2:]).sevenbit = base64.urlsafe_b64decode(parts[1]+'==')
for p in parts[2:]:
l, d = p.split('=', maxsplit=1)
if l == 'PN':
s.mrag.magazine = int(d[0], 16)
s.header.page = int(d[1:3], 16)
elif l == 'PS':
c = int(d, 16)
s.header.control = (c<<1) | ((c&1)>>14)
elif l == 'SC':
pass # TODO
elif l == 'X25':
pass # TODO
elif l == 'X270':
pass # TODO
elif l == 'X280':
pass # TODO
elif l == 'X284':
pass # TODO
return s
@classmethod
def from_file(cls, f):
chunks = FileChunker(f, 42)
packets = (Packet(data, number) for number, data in chunks)
return cls.from_packets(packets)
@property
def packets(self):
for n, a in enumerate(self._array):
if self._numbers[n] > -100:
yield Packet(a, number=None if self._numbers[n] < 0 else self._numbers[n])
@property
def mrg_PN(self):
return f'{self.mrag.magazine}{self.header.page:02x}'
def mrg_PS(self, transmit=False):
c = self.header.control
c = (c>>1) | ((c&1)<<14)
if transmit:
c |= 1<<15
return f'{c:x}'
@property
def mrg_SC(self):
return f'{self.header.subpage:x}'
@property
def url(self):
data = self._array[0:25,2:].copy()
data[0, :8] = 0x20
parts = [
'0',
base64.urlsafe_b64encode(Element((25, 40), data).sevenbit).decode('ascii').rstrip('='),
f'PN={self.mrg_PN}',
f'PS={self.mrg_PS()}',
f'SC={self.mrg_SC}',
]
if self.has_packet(25):
parts.append('X25=' + base64.urlsafe_b64encode(Element((1, 40), self._array[25:26,2:]).sevenbit).decode('ascii').rstrip('='))
for d in range(16):
if self.has_packet(26, d):
pass # TODO: X26
if self.has_packet(27, 0):
parts.append('X270=' + ''.join([f'{l.magazine}{l.page:02x}{l.subpage:04x}' for l in self.fastext.links]) + f'{self.fastext.control:1x}')
if self.has_packet(28, 0):
pass # TODO: X280
if self.has_packet(28, 4):
pass # TODO: X284
return ':'.join(parts)
def to_tti(self, cycle_time=None, transmit=True):
parts = [
f'PN,{self.mrg_PN}00',
f'SC,{self.mrg_SC}',
f'PS,{self.mrg_PS(transmit)}',
]
if cycle_time is not None:
parts.append(f'CT,{cycle_time}')
parts.extend(f'OL,{line+1},{data}' for line, data in enumerate(self.displayable.to_tti()))
links = ','.join(f'{l.magazine}{l.page:02x}' for l in self.fastext.links)
parts.append(f'FL,{links}')
return ('\r\n'.join(parts) + '\r\n').encode('ascii')
def to_html(self, pages_set, localcodepage=None):
lines = []
lines.append(f'
')
buf = np.full((40,), fill_value=0x20, dtype=np.uint8)
buf[3:7] = np.fromstring(f'P{self.mrag.magazine}{self.header.page:02x}', dtype=np.uint8)
buf[8:] = self.header.displayable[:]
p = PrinterHTML(buf, localcodepage=localcodepage, codepage=self.codepage)
p.anchor = f'#{self.header.subpage:04x}'
lines.append(str(p))
for i in range(0,24):
# only draw the line if previous line does not contain double height code
if i == 0 or np.all(self.displayable[i-1,:] != 0x0d):
fastext = [f'{l.magazine}{l.page:02x}' for l in self.fastext.links] if i == 23 else None
p = PrinterHTML(self.displayable[i,:], fastext=fastext, pages_set=pages_set, localcodepage=localcodepage, codepage=self.codepage)
lines.append(str(p))
lines.append('
')
return ''.join(lines)
================================================
FILE: teletext/ts.py
================================================
# Based on https://github.com/fsphil/tstxtdump with assistance from the author :)
import itertools
import struct
from .coding import byte_reverse
def parse_data(data):
pos = 0
while (len(data) - pos) >= 46:
if data[pos] in [2, 3]:
yield bytes(byte_reverse(b) for b in data[pos+4:pos+4+42])
pos += 46
def parse_pes(pes):
pos = 0
while (len(pes) - pos) >= 9:
l, o = struct.unpack('!xxxxHxxB', pes[pos:pos+9])
yield from parse_data(pes[pos+10+o:pos+l-o-4])
pos += l + 6
def pidextract(packets, pid):
pes = []
count = itertools.count()
start_seen = False
for n, packet in packets:
t, p, c = struct.unpack('!BHB', packet[:4])
o = 4
if t == 0x47 and (p&0x1fff) == pid:
if p & 0x4000:
if pes:
yield from ((y, x) for x, y in zip(parse_pes(b''.join(pes)), count))
pes = []
start_seen = True
if start_seen:
if c & 0x20: # adaptation field
o += packet[4] + 1
pes.append(packet[o:])
================================================
FILE: teletext/vbi/__init__.py
================================================
================================================
FILE: teletext/vbi/clustering.py
================================================
import pathlib
import numpy as np
from collections import defaultdict
from itertools import islice
from binascii import hexlify
def cluster(a, l, clusters=None, steps=None):
if clusters is None:
clusters = defaultdict(list)
if steps is None:
steps = np.floor(np.linspace(0, a.shape[1]-5, num=11)).astype(np.uint32)[[1, 5, 10]]
v = np.empty((a.shape[0], 5), dtype=np.uint8)
v[:, 0] = l
v[:, 1] = np.floor(np.mean(np.abs(np.diff(a.astype(np.int16), axis=1)), axis=1)).astype(np.uint8)
v[:, 2:] = np.diff(np.sort(a, axis=1)[:, steps] >> 4, axis=1, prepend=0)
for vv, aa in zip(v, a):
clusters[vv.tobytes()].append(aa)
return v, clusters
def batched(iterable, n):
"Batch data into lists of length n. The last batch may be shorter."
# batched('ABCDEFG', 3) --> ABC DEF G
it = iter(iterable)
while True:
batch = list(islice(it, n))
if not batch:
return
yield batch
def batch_cluster(chunks, output, prefix="", lpf=32):
output = pathlib.Path(output)
with (output / f'{prefix}map.bin').open('wb') as mapfile:
for batch in batched(chunks, 10000):
a = np.stack(list(np.frombuffer(i[1], dtype=np.uint8) for i in batch))
l = np.array(list(i[0] for i in batch)) % lpf
map, clusters = cluster(a, l)
mapfile.write(map.tobytes())
for k, v in clusters.items():
p = output / f'{prefix}{hexlify(k).decode("utf8")}.vbi'
with p.open('ab') as f:
for l in v:
f.write(l.tobytes())
def rendermap(config, map, output):
from PIL import Image
import math
a = np.fromfile(map, dtype=np.uint8).reshape(-1, config.frame_lines, 5)
rows = []
frames = 25 * 60
for n in range(0, a.shape[0], frames):
r = a[n:n+frames]
if r.shape[0] < frames:
r = np.concatenate([r, np.zeros((frames-r.shape[0], config.frame_lines, 5), dtype=np.uint8)], axis=0)
r = np.swapaxes(r, 0, 1)
rows.append(r)
i = np.concatenate(rows, axis=0) * 20
i = Image.fromarray(i[:,:,[1, 3, 4]], mode="RGB")
i.save(output)
================================================
FILE: teletext/vbi/config.py
================================================
import math
import pathlib
import numpy as np
class Config(object):
teletext_bitrate = 6937500.0
gauss = 4.0
std_thresh = 14
sample_rate: float
line_length: int
line_start_range: tuple
dtype: type
field_lines: int
field_range: range
extra_roll: int = 0
sample_rate_adjust: float = 0
# Clock run-in and framing code. These bits are set at the start of every teletext packet.
crifc = np.array((
1, -1, 1, -1, 1, -1, 1, -1,
1, -1, 1, -1, 1, -1, 1, -1,
1, 1, 1, -1, -1, 1, -1, -1,
))
observed_crifc = np.array([
[133, 132, 129, 127, 124, 121, 119, 117],
[116, 115, 115, 115, 116, 117, 118, 119],
[120, 121, 121, 121, 121, 120, 119, 118],
[118, 117, 116, 116, 116, 117, 117, 118],
[119, 120, 120, 121, 121, 121, 120, 119],
[119, 118, 117, 116, 116, 116, 116, 117],
[118, 119, 120, 121, 122, 122, 122, 122],
[121, 120, 119, 118, 117, 117, 117, 117],
[118, 118, 119, 120, 121, 121, 121, 121],
[121, 120, 119, 119, 118, 118, 117, 117],
[118, 118, 119, 120, 121, 122, 122, 122],
[122, 121, 120, 119, 118, 118, 117, 117],
[117, 117, 118, 119, 120, 120, 121, 121],
[122, 122, 122, 122, 121, 121, 121, 121],
[120, 120, 119, 118, 116, 115, 113, 110],
[108, 105, 104, 103, 104, 107, 112, 119],
[128, 137, 147, 157, 166, 174, 179, 183],
[184, 183, 181, 178, 175, 171, 168, 166],
[164, 163, 162, 160, 159, 156, 153, 147],
[141, 133, 124, 114, 104, 96, 88, 82],
[ 78, 77, 79, 83, 90, 99, 108, 118],
[127, 134, 140, 144, 146, 145, 141, 136],
[128, 119, 110, 100, 91, 83, 76, 69],
[ 65, 61, 59, 57, 57, 57, 57, 58]
], dtype=np.uint8)
observed_crifc_gradient = np.gradient(observed_crifc[8:24,:].flatten())
# Card specific default parameters:
cards = {
'bt8x8': {
'sample_rate': 35468950.0,
'line_length': 2048,
'line_start_range': (60, 130),
'dtype': np.uint8,
'field_lines': 16,
'field_range': range(0, 16),
},
'cx88': {
'sample_rate': 35468950.0,
'line_length': 2048,
'line_start_range': (90, 150),
'dtype': np.uint8,
'field_lines': 18,
'field_range': range(1, 17),
},
'tbc': { # ld-decode/vhs-decode tbc (full fields)
'sample_rate': 17730000.0,
'line_length': 1135,
'line_start_range': (160, 190),
'dtype': np.uint16,
'field_lines': 313,
'field_range': range(6, 22),
},
'tbc-vbi': { # ld-decode/vhs-decode tbc (vbi only)
'sample_rate': 17730000.0,
'line_length': 1135,
'line_start_range': (160, 190),
'dtype': np.uint16,
'field_lines': 16,
'field_range': range(0, 16),
},
'saa7131': {
'sample_rate': 27000000.0,
'line_length': 1440,
'line_start_range': (0, 20),
'dtype': np.uint8,
'field_lines': 16,
'field_range': range(0, 16),
},
}
def __init__(self, card='bt8x8', **kwargs):
setattr(self, 'card', card)
for k, v in self.cards[card].items():
setattr(self, k, v)
for k, v in kwargs.items():
if v is not None:
setattr(self, k, v)
self.frame_lines = self.field_lines * 2
self.sample_rate += self.sample_rate_adjust
# width of a bit in samples (float)
self.bit_width = self.sample_rate / self.teletext_bitrate
results = []
for pad in range(500):
r = (self.line_length+pad) * 8 / self.bit_width
rs = round(r)
err = abs(r - rs)
results.append((err, pad, rs))
# resample params
self.resample_pad, self.resample_tgt = min(results)[1:]
self.resample_size = math.ceil(self.line_length * 8 / self.bit_width)
# region of the original line where the CRI begins, in samples
self.start_slice = slice(
math.floor(self.line_start_range[0] * 8 / self.bit_width),
math.ceil(self.line_start_range[1] * 8 / self.bit_width)
)
# last sample of original line where teletext may occur
self.line_trim = self.start_slice.stop + math.ceil(8 * 45 * 8)
# fft
self.fftbins = [0, 47, 54, 97, 104, 147, 154, 197, 204]
def __repr__(self):
return f'{type(self).__name__}: {self.__dict__}'
@property
def line_bytes(self):
return self.line_length * np.dtype(self.dtype).itemsize
__datadir = pathlib.Path(__file__).parent.parent / 'vbi' / 'data'
tape_formats = [f.name for f in __datadir.iterdir() if f.is_dir()]
================================================
FILE: teletext/vbi/line.py
================================================
# * Copyright 2016 Alistair Buxton
# *
# * License: This program is free software; you can redistribute it and/or
# * modify it under the terms of the GNU General Public License as published
# * by the Free Software Foundation; either version 3 of the License, or (at
# * your option) any later version. This program is distributed in the hope
# * that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# * GNU General Public License for more details.
import importlib
import math
import pathlib
import sys
import numpy as np
from scipy.ndimage import gaussian_filter1d as gauss
from scipy.signal import resample
from teletext.packet import Packet
from teletext.elements import Mrag, DesignationCode
from .config import Config
def normalise(a, start=None, end=None):
mn = a[start:end].min()
mx = a[start:end].max()
r = (mx-mn)
if r == 0:
r = 1
return np.clip((a.astype(np.float32) - mn) * (255.0/r), 0, 255)
# Line: Handles a single line of raw VBI samples.
class Line(object):
"""Container for a single line of raw samples."""
config: Config
configured = False
@classmethod
def configure_patterns(cls, method, tape_format):
try:
module = importlib.import_module(".pattern" + method.lower(), __package__)
Pattern = getattr(module, "Pattern" + method)
datadir = pathlib.Path(__file__).parent / 'data' / tape_format
cls.h = Pattern(datadir / 'hamming.dat')
cls.p = Pattern(datadir / 'parity.dat')
cls.f = Pattern(datadir / 'full.dat')
return True
except Exception as e:
sys.stderr.write(str(e) + '\n')
sys.stderr.write((method if method else 'CPU') + ' init failed.\n')
return False
@classmethod
def configure(cls, config, force_cpu=False, prefer_opencl=False, tape_format='vhs'):
cls.config = config
if force_cpu:
methods = ['']
elif prefer_opencl:
methods = ['OpenCL', 'CUDA', '']
else:
methods = ['CUDA', 'OpenCL', '']
if any(cls.configure_patterns(method, tape_format) for method in methods):
cls.configured = True
else:
raise Exception('Could not initialize any deconvolution method.')
def __init__(self, data, number=None):
if not self.configured:
self.configure(Config())
self._number = number
self._original = np.frombuffer(data, dtype=self.config.dtype).astype(np.float32)
self._original /= 256 ** (np.dtype(self.config.dtype).itemsize-1)
self._original_bytes = data
resample_tmp = np.pad(self._original, (0, self.config.resample_pad), 'constant', constant_values=(0,0))
self._resampled = np.pad(resample(resample_tmp, self.config.resample_tgt)[:self.config.resample_size], (0, 64), 'edge')
self.reset()
def reset(self):
"""Reset line to original unknown state."""
self.roll = 0
self._noisefloor = None
self._max = None
self._fft = None
self._gstart = None
self._is_teletext = None
self._start = None
self._reason = None
@property
def resampled(self):
"""The resampled line. 8 samples = 1 bit."""
return self._resampled[:]
@property
def original(self):
"""The raw, untouched line."""
return self._original[:]
@property
def rolled(self):
if self.start is not None:
return np.roll(self._resampled, 90-(self.start+self.roll))
else:
return self._resampled[:]
@property
def gradient(self):
return (np.gradient(gauss(self.rolled, 12))[20:300]>0)*255
def fchop(self, start, stop):
"""Chop the samples associated with each bit."""
# This should use self.start not self._start so that self._start
# is calculated if it hasn't been already.
r = (self.start + self.roll)
# sys.stderr.write(f'{r}, {start}, {stop}, {d.shape}\n')
return self._resampled[r + (start * 8):r + (stop * 8)]
def chop(self, start, stop):
"""Average the samples associated with each bit."""
return np.mean(self.fchop(start, stop).reshape(-1, 8), 1)
@property
def chopped(self):
"""The whole chopped teletext line, for vbi viewer."""
return self.chop(0, 360)
@property
def noisefloor(self):
if self._noisefloor is None:
if self.config.start_slice.start == 0:
self._noisefloor = np.max(gauss(self._resampled[self.config.line_trim:-4], self.config.gauss))
else:
self._noisefloor = np.max(gauss(self._resampled[:self.config.start_slice.start], self.config.gauss))
return self._noisefloor
@property
def fft(self):
"""The FFT of the original line."""
if self._fft is None:
# This test only looks at the bins for the harmonics.
# It could be made smarter by looking at all bins.
self._fft = normalise(gauss(np.abs(np.fft.fft(np.diff(self._resampled[:3200], n=1))[:256]), 4))
return self._fft
def find_start(self):
# First try to detect by comparing pre-start noise floor to post-start levels.
# Store self._gstart so that self.start can re-use it.
self._gstart = gauss(self._resampled[self.config.start_slice], self.config.gauss)
smax = np.max(self._gstart)
if smax < 64:
self._is_teletext = False
self._reason = f'Signal max is {smax}'
elif self.noisefloor > 80:
self._is_teletext = False
self._reason = f'Noise is {self.noisefloor}'
elif smax < (self.noisefloor + 16):
# There is no interesting signal in the start_slice.
self._is_teletext = False
self._reason = f'Noise is higher than signal {smax} {self.noisefloor}'
else:
# There is some kind of signal in the line. Check if
# it is teletext by looking for harmonics of teletext
# symbol rate.
fftchop = np.add.reduceat(self.fft, self.config.fftbins)
self._is_teletext = np.sum(fftchop[1:-1:2]) > 1000
if not self._is_teletext:
return
# Find the steepest part of the line within start_slice.
# This gives a rough location of the start.
self._start = np.argmax(np.gradient(np.maximum.accumulate(self._gstart))) + self.config.start_slice.start
# Now find the extra roll needed to lock in the clock run-in and framing code.
confidence = []
for roll in range(max(-30, 8-self._start), 20):
self.roll = roll
# 15:20 is the last bit of CRI and first 4 bits of FC - 01110.
# This is the most distinctive part of the CRI/FC to look for.
c = self.chop(15, 21)
confidence.append((c[1] + c[2] + c[3] - c[0] - c[4] - c[5], roll))
#confidence.append((np.sum(self.chop(15, 20) * self.config.crifc[15:20]), roll))
self._start += max(confidence)[1]
self.roll = 0
# Use the observed CRIFC to lock to the framing code
confidence = []
for roll in range(-4, 4):
self.roll = roll
x = np.gradient(self.fchop(8, 24))
c = np.sum(np.square(x - self.config.observed_crifc_gradient))
confidence.append((c, roll))
self._start += min(confidence)[1]
self.roll = 0
self._start += self.config.extra_roll
@property
def is_teletext(self):
"""Determine whether the VBI data in this line contains a teletext signal."""
if self._is_teletext is None:
self.find_start()
return self._is_teletext
@property
def start(self):
"""Find the offset in samples where teletext data begins in the line."""
if self.is_teletext:
return self._start
else:
return None
def deconvolve(self, mags=range(9), rows=range(32), eight_bit=False):
"""Recover original teletext packet by pattern recognition."""
if not self.is_teletext:
return 'rejected'
bytes_array = np.zeros((42,), dtype=np.uint8)
# Note: 368 (46*8) not 360 (45*8), because pattern matchers need an
# extra byte on either side of the input byte(s) we want to match for.
# The framing code serves this purpose at the beginning as we never
# need to match it. We need just an extra byte at the end.
bits_array = normalise(self.chop(0, 368))
# First match just the mrag and dc for the line.
bytes_array[:3] = self.h.match(bits_array[16:56])
m = Mrag(bytes_array[:2])
d = DesignationCode((1, ), bytes_array[2:3])
if m.magazine in mags and m.row in rows:
if m.row == 0:
bytes_array[3:10] = self.h.match(bits_array[40:112])
bytes_array[10:] = self.p.match(bits_array[96:368])
elif m.row < 26:
if eight_bit:
bytes_array[2:] = self.f.match(bits_array[32:368])
else:
bytes_array[2:] = self.p.match(bits_array[32:368])
elif m.row == 27:
if d.dc < 4:
bytes_array[3:40] = self.h.match(bits_array[40:352])
bytes_array[40:] = self.f.match(bits_array[336:368])
else:
bytes_array[3:] = self.f.match(bits_array[40:368]) # TODO: proper codings
elif m.row < 30:
bytes_array[3:] = self.f.match(bits_array[40:368]) # TODO: proper codings
elif m.row == 30 and m.magazine == 8: # BDSP
bytes_array[3:9] = self.h.match(bits_array[40:104]) # initial page
if d.dc in [2, 3]:
bytes_array[9:22] = self.h.match(bits_array[88:208]) # 8-bit data
else:
bytes_array[9:22] = self.f.match(bits_array[88:208]) # 8-bit data
bytes_array[22:] = self.p.match(bits_array[192:368]) # status display
else:
bytes_array[3:] = self.f.match(bits_array[40:368]) # TODO: proper codings
return Packet(bytes_array, number=self._number, original=self._original_bytes)
else:
return 'filtered'
def slice(self, mags=range(9), rows=range(32), eight_bit=False):
"""Recover original teletext packet by threshold and differential."""
if not self.is_teletext:
return 'rejected'
# Note: 23 (last bit of FC), not 24 (first bit of MRAG) because
# taking the difference reduces array length by 1. We cut the
# extra bit off when taking the threshold.
bits_array = normalise(self.chop(23, 360))
diff = np.diff(bits_array, n=1)
ones = (diff > 48)
zeros = (diff > -48)
result = ((bits_array[1:] > 127) | ones) & zeros
packet = Packet(np.packbits(result.reshape(-1,8)[:,::-1]), number=self._number, original=self._original_bytes)
m = packet.mrag
if m.magazine in mags and m.row in rows:
return packet
else:
return 'filtered'
def process_lines(chunks, mode, config, force_cpu=False, prefer_opencl=False, mags=range(9), rows=range(32), tape_format='vhs', eight_bit=False):
if mode == 'slice':
force_cpu = True
Line.configure(config, force_cpu, prefer_opencl, tape_format)
for number, chunk in chunks:
try:
yield getattr(Line(chunk, number), mode)(mags, rows, eight_bit)
except Exception:
sys.stderr.write(str(number) + '\n')
raise
================================================
FILE: teletext/vbi/pattern.py
================================================
# * Copyright 2016 Alistair Buxton
# *
# * License: This program is free software; you can redistribute it and/or
# * modify it under the terms of the GNU General Public License as published
# * by the Free Software Foundation; either version 3 of the License, or (at
# * your option) any later version. This program is distributed in the hope
# * that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# * GNU General Public License for more details.
import itertools
import struct
from collections import defaultdict
import numpy as np
from tqdm import tqdm
class Pattern(object):
def __init__(self, filename):
with open(filename, 'rb') as f:
self.inlen,self.outlen,self.n,self.start,self.end = struct.unpack('>IIIBB', f.read(14))
self.patterns = np.fromfile(f, dtype=np.uint8, count=self.inlen*self.n)
self.patterns = self.patterns.reshape((self.n, self.inlen))
self.patterns = self.patterns.astype(np.float32)
self.bytes = np.fromfile(f, dtype=np.uint8, count=self.outlen*self.n)
self.bytes = self.bytes.reshape((self.n, self.outlen))
self.pslice = self.patterns[:, self.start:self.end]
def match(self, inp):
l = (len(inp)//8)-2
idx = np.empty((l,), dtype=np.uint32)
for i in range(l):
start = (i*8) + self.start
end = (i*8) + self.end
diffs = self.pslice - inp[start:end]
diffs = diffs * diffs
idx[i] = np.argmin(np.sum(diffs, axis=1))
return self.bytes[idx][:,0]
def similarities(self):
def norm(arr):
mn = np.nanmin(arr)
mx = np.nanmax(arr)
print(mn, mx)
r = (mx - mn)
if r == 0:
r = 1
return np.clip((arr - mn) * (255.0 / r), 0, 255)
s = defaultdict(list)
for x in tqdm(range(0, self.n)):
for y in range(x+1, self.n):
d = np.sum(np.square(self.pslice[x] - self.pslice[y]))
s[(self.bytes[x][0]&0x7f, self.bytes[y][0]&0x7f)].append(d)
result = np.full((256, 256, 3), dtype=np.float32, fill_value=float('nan'))
for k, v in s.items():
if v:
x, y = sorted(k)
result[x, y, 0] = min(v)
result[x, y, 1] = sum(v)/len(v)
result[x, y, 2] = max(v)
result = norm(result)
def get(x, y):
x, y = sorted((x, y))
return (x, y), result[ord(x), ord(y)].astype(np.uint8), len(s[ord(x), ord(y)])
errors = []
for c, d in itertools.combinations('abcdefghijklmnopqrstuvwxyz', 2):
r = get(c, d)
if r[1][0] < 5:
errors.append(c+d)
errorsu = []
for c, d in itertools.combinations('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 2):
r = get(c, d)
if r[1][0] < 5:
errorsu.append(c+d)
print(errorsu)
return errors
# Classes used to build pattern files from training data.
# Not used during normal decoding.
class PatternBuilder(object):
def __init__(self, inwidth):
self.patterns = defaultdict(list)
self.inwidth = inwidth
def write_patterns(self, f, start, end):
flat_patterns = []
for k, v in tqdm(self.patterns.items(), unit='P', desc='Squashing'):
pattn = np.mean(np.frombuffer(b''.join(v), dtype=np.uint8).reshape((len(v), self.inwidth)), axis=0).astype(np.uint8)
flat_patterns.append((pattn, k[1:2]))
header = struct.pack('>IIIBB', len(flat_patterns[0][0]), len(flat_patterns[0][1]), len(flat_patterns), start, end)
f.write(header)
for (p,b) in flat_patterns:
f.write(p)
for (p,b) in flat_patterns:
f.write(b)
f.close()
def add_pattern(self, key, pattern):
self.patterns[key].append(pattern)
def build_pattern(chunks, output, start, end, pattern_set=range(256)):
#build_pattern(squashed, 'full.dat', 3, 19)
#build_pattern(squashed, 'parity.dat', 4, 18, parity_set)
#build_pattern(squashed, 'hamming.dat', 1, 20, hamming_set)
pb = PatternBuilder(24)
def key(s):
pre = s[0]&(0xff<>(24-end))
return bytes((pre, line[1], post))
for n, line in chunks:
if line[1] in pattern_set:
pb.add_pattern(key(line), line[3:])
pb.write_patterns(output, start, end)
================================================
FILE: teletext/vbi/patterncuda.py
================================================
# * Copyright 2016 Alistair Buxton
# *
# * License: This program is free software; you can redistribute it and/or
# * modify it under the terms of the GNU General Public License as published
# * by the Free Software Foundation; either version 3 of the License, or (at
# * your option) any later version. This program is distributed in the hope
# * that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# * GNU General Public License for more details.
import atexit
import numpy as np
import pycuda.driver as cuda
import pycuda.gpuarray as gpuarray
from pycuda.compiler import SourceModule
from pycuda.driver import ctx_flags
from .pattern import Pattern
cuda.init()
cudadevice = cuda.Device(0)
cudacontext = cudadevice.make_context(flags=ctx_flags.SCHED_YIELD)
atexit.register(cudacontext.pop)
class PatternCUDA(Pattern):
correlate = SourceModule("""
__global__ void correlate(float *input, float *patterns, float *result, int range_low, int range_high)
{
int x = (threadIdx.x + (blockDim.x*blockIdx.x));
int y = (threadIdx.y + (blockDim.y*blockIdx.y));
int iidx = x * 8;
int ridx = (x * blockDim.y * gridDim.y) + y;
int pidx = y * 24;
float d;
result[ridx] = 0;
for (int i=range_low;i
# * based on Alistair's patterncuda.py
# *
# * License: This program is free software; you can redistribute it and/or
# * modify it under the terms of the GNU General Public License as published
# * by the Free Software Foundation; either version 3 of the License, or (at
# * your option) any later version. This program is distributed in the hope
# * that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# * GNU General Public License for more details.
import numpy as np
import pyopencl as cl
import sys
from .pattern import Pattern
openclctx = cl.create_some_context(interactive=False)
class PatternOpenCL(Pattern):
prg = cl.Program(openclctx, """
__kernel void correlate(global float* restrict input, global float* restrict patterns, global float* restrict result,
int range_low, int range_high)
{
int x = get_global_id(0);
int y = get_global_id(1);
int iidx = x * 8;
int ridx = (x * get_global_size(1)) + y;
int pidx = y * 24;
float d;
input+=iidx+range_low;
patterns+=pidx+range_low;
range_high-=range_low;
int i=0;
float res = 0;
float4 vd;
for (;(i+3) 32768:
self.minpar = 1024
else:
self.minpar = 512
# Temporaries used during parallel min (value and index)
self.mintmp_val = cl.Buffer(openclctx, mf.HOST_NO_ACCESS, 4*40*self.minpar)
self.mintmp_idx = cl.Buffer(openclctx, mf.HOST_NO_ACCESS, 4*40*self.minpar)
# output of the min pass - an integer index to which pattern was best
# for each character
self.result_minidx = cl.Buffer(openclctx, mf.WRITE_ONLY, 4*40)
# and a copy of that for np
self.result_minidx_np = np.zeros(40, dtype=np.uint32)
def match(self, inp):
l = (len(inp)//8)-2
x = l & -l # highest power of two which divides l, up to 8
y = min(1024//x, self.n)
# copy data in
e_copy = cl.enqueue_copy(self.queue, self.input_match, inp.astype(np.float32), is_blocking = False)
# call corellate
# Output is one row per character, with one value per pattern
self.kernel_correlate.set_args(self.input_match, self.patterns_gpu, self.result_match,
np.int32(self.start), np.int32(self.end))
e_corr = cl.enqueue_nd_range_kernel(self.queue, self.kernel_correlate,
(l, self.n), None,
wait_for = (e_copy,))
# Run min pass 1
# squashes the set of patterns down into minpar minima
assert (self.n % self.minpar) == 0
self.kernel_min1.set_args(self.result_match,
self.mintmp_val, self.mintmp_idx,
np.int32(self.n), np.int32(self.minpar))
e_min1 = cl.enqueue_nd_range_kernel(self.queue, self.kernel_min1,
(l,self.minpar), None,
wait_for = (e_corr,))
# Run min pass 2
# squashes the temporaries down to a final minimum index for each char
self.kernel_min2.set_args(self.mintmp_val, self.mintmp_idx,
self.result_minidx,
np.int32(self.minpar))
e_min2 = cl.enqueue_nd_range_kernel(self.queue, self.kernel_min2,
(l,), None,
wait_for = (e_min1,))
# and get the index values back from OpenCL
e_out = cl.enqueue_copy(self.queue, self.result_minidx_np, self.result_minidx, wait_for = (e_min2,))
e_out.wait()
if self.profile:
print('s/e: {}/{} n: {} len: {} / total: {} Copy: {} correlate: {} min1: {} min2: {} copy-out: {}'.format(
self.start, self.end,
self.n, len(inp),
e_out.profile.end-e_copy.profile.start,
e_copy.profile.end-e_copy.profile.start,
e_corr.profile.end-e_corr.profile.start,
e_min1.profile.end-e_min1.profile.start,
e_min2.profile.end-e_min2.profile.start,
e_out.profile.end-e_out.profile.start),
file=sys.stderr)
return self.bytes[self.result_minidx_np[:l],0]
================================================
FILE: teletext/vbi/training.py
================================================
# * Copyright 2016 Alistair Buxton
# *
# * License: This program is free software; you can redistribute it and/or
# * modify it under the terms of the GNU General Public License as published
# * by the Free Software Foundation; either version 3 of the License, or (at
# * your option) any later version. This program is distributed in the hope
# * that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# * GNU General Public License for more details.
import os
import itertools
import sys
import numpy as np
from tqdm import tqdm
from teletext.file import FileChunker
from teletext.coding import parity_encode, hamming8_enc as hamming_set
from teletext.vbi.line import Line, normalise
from .pattern import build_pattern
class PatternGenerator(object):
pattern = None
# pattern_length is the number of bytes in the teletext data available for patterns.
pattern_length = 27
def __init__(self):
if self.pattern is None:
self.load_pattern()
@classmethod
def load_pattern(cls):
with open(os.path.join(os.path.dirname(__file__), 'data', 'debruijn.dat'), 'rb') as db:
data = db.read()
cls.pattern = np.frombuffer(data + data[:cls.pattern_length], dtype=np.uint8)
def checksum(self, array):
return array[0] ^ array[1] ^ array[2] ^ 0xf0
def generate_line(self, offset):
line = np.zeros((42,), dtype=np.uint8)
# constant bytes. can be used for horizontal alignment.
line[0] = 0x18
line[1 + self.pattern_length] = 0x18
line[41] = 0x18
# insert pattern slice into line
line[1:1 + self.pattern_length] = self.pattern[offset:offset + self.pattern_length]
# encode the offset for maximum readability
offset_list = [(offset >> n) & 0xff for n in range(0, 24, 8)]
# add a checksum
offset_list.append(self.checksum(offset_list))
# convert to a list of bits, LSB first
offset_arr = np.array(offset_list, dtype=np.uint8)
# repeat each bit 3 times, then convert back in to t42 bytes
offset_arr = np.packbits(np.repeat(np.unpackbits(offset_arr[::-1])[::-1], 3)[::-1])[::-1]
# insert encoded offset into line
line[2 + self.pattern_length:14 + self.pattern_length] = offset_arr
return line
def to_file(self, file):
offset = 0
while True:
line = self.generate_line(offset)
# calculate next offset for maximum distance
offset += 65521 # greatest prime less than 2097152/32
offset &= 0x1fffff # mod 2097152
# write to stdout
file.write(line.tobytes())
def de_bruijn(k, n):
a = [0] * k * n
sequence = []
def db(t, p):
if t > n:
if n % p == 0:
sequence.extend(a[1:p + 1])
else:
a[t] = a[t - p]
db(t + 1, p)
for j in range(a[t - p] + 1, k):
a[t] = j
db(t + 1, t)
db(1, 1)
return sequence
def save_pattern(filename):
pattern = np.packbits(np.array(de_bruijn(2, 24), dtype=np.uint8)[::-1])[::-1]
with open(filename, 'wb') as data:
pattern.tofile(data)
class TrainingLine(Line):
pgen = PatternGenerator()
def tchop(self, start, stop):
s = np.sum(self.chop(256+(start*24), 256+(stop*24)).reshape(-1, 3), 1)
s = (s > 384).astype(np.uint8)
return np.packbits(s[::-1])[::-1]
def lock(self, offset):
orig = np.empty((45*8), dtype=np.float)
orig[:24] = self.config.crifc * 255
orig[24:] = np.unpackbits(self.pgen.generate_line(offset)[::-1])[::-1] * 255
x = []
for roll in range(-10, 10):
self.roll = roll
t = np.sum(np.square(self.chop(0, 360)-orig))
x.append((t, roll))
roll = min(x)[1]
self.roll = 0
self._start += roll
#print(roll)
@property
def checksum(self):
return self.tchop(3, 4)[0]
@property
def offset(self):
for roll in range(-8, 8):
self.roll = roll
bytes = self.tchop(0, 3)
if self.pgen.checksum(bytes) == self.checksum:
offset = bytes[0] | (bytes[1] << 8) | (bytes[2] << 16)
if offset < 0x1fffff:
self._start += roll
self.roll = 0
self.lock(offset)
return offset
#sys.stderr.write(f'Warning: bad line {self._number}\n')
def process_training(chunks, config):
TrainingLine.configure(config, force_cpu=True)
lines = (TrainingLine(chunk, n) for n, chunk in chunks)
for l in lines:
if l.is_teletext:
offset = l.offset
if offset is not None:
yield (offset, normalise(l.chop(32, 32+(8*TrainingLine.pgen.pattern_length))).astype(np.uint8))
continue
yield 'rejected'
def process_crifc(chunks, config):
TrainingLine.configure(config, force_cpu=True)
lines = (TrainingLine(chunk, n) for n, chunk in chunks)
n = 1000
crifc = np.empty((n, 192), dtype=np.float)
for l in lines:
if l.is_teletext:
offset = l.offset
if offset is not None:
crifc[n-1] = l.fchop(0, 24)
n -= 1
if n == 0:
break
mean = np.mean(crifc, 0).reshape(-1, 8)
print(repr(mean.astype(np.uint8)))
def split(data, files):
pgen = PatternGenerator()
chopped_indexer = np.arange(24)[None, :] + np.arange((8 * pgen.pattern_length) - 23)[:, None]
pattern_indexer = chopped_indexer[::-1,:]
for offset, chopped in data:
# Fetch the pattern block corresponding to this line.
block = np.unpackbits(pgen.pattern[offset:offset + pgen.pattern_length][::-1])
# Sliding window through the pattern block.
patterns = np.packbits(block[pattern_indexer], axis=1)[:, ::-1]
# Sliding window through the chopped line.
choppeds = chopped[chopped_indexer]
# Append chopped samples to pattern bytes.
result = np.append(patterns, choppeds, axis=1)
for p in result:
files[p[0]].write(p.tobytes())
def squash(output, indir):
for n in tqdm(range(256), unit='File'):
with open(os.path.join(indir, f'training.{n:02x}.dat'), 'rb') as f:
chunks = FileChunker(f, 27)
chunks = sorted(chunk for n, chunk in chunks)
for k, g in itertools.groupby(chunks, lambda x: x[:3]):
a = list(g)
b = np.frombuffer(b''.join(a), dtype=np.uint8).reshape((len(a), 27))
b = np.mean(b, axis=0).astype(np.uint8)
output.write(b.tobytes())
================================================
FILE: teletext/vbi/viewer.py
================================================
import time
import numpy as np
from itertools import islice
from OpenGL.GLUT import *
from OpenGL.GL import *
class VBIViewer(object):
def __init__(self, lines, config, name = "VBI Viewer", width=800, height=512, nlines=32, tint=True, show_grid=True, show_slices=False, pause=False):
self.config = config
self.show_grid = show_grid
self.tint = tint
self.pause = pause
self.single_step = False
self.name = name
self.line_attr = 'resampled'
if nlines is None:
self.nlines = 32
else:
self.nlines = nlines
self.lines_src = lines
self.lines = list(islice(self.lines_src, 0, self.nlines))
glutInit(sys.argv)
glutInitDisplayMode(GLUT_SINGLE | GLUT_RGB)
glutInitWindowSize(width,height)
glutCreateWindow(name)
self.set_title()
glutDisplayFunc(self.display)
glutReshapeFunc(self.reshape)
glutKeyboardFunc(self.keyboard)
glutMouseFunc(self.mouse)
glMatrixMode(GL_PROJECTION)
glOrtho(0, config.resample_size, 0, self.nlines, -1, 1)
glMatrixMode(GL_MODELVIEW)
glLoadIdentity()
glDisable(GL_LIGHTING)
glDisable(GL_DEPTH_TEST)
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
glEnable(GL_TEXTURE_2D)
glBindTexture(GL_TEXTURE_2D, glGenTextures(1))
glPixelStorei(GL_UNPACK_ALIGNMENT,1)
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST)
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST)
glTexEnvf(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE)
glutMainLoop()
def reshape(self, width, height):
self.width = width
self.height = height
glViewport(0, 0, width, height)
def keyboard(self, key, x, y):
if key == b'g':
self.show_grid ^= True
elif key == b'c':
self.tint ^= True
elif key == b'p':
self.pause ^= True
elif key == b'n':
self.single_step = True
elif key == b'r':
self.dumpline(x, y, teletext=False)
elif key == b't':
self.dumpline(x, y, teletext=True)
elif key == b'R':
self.dumpall(teletext=False)
elif key == b'T':
self.dumpall(teletext=True)
elif key == b'1':
self.line_attr = 'resampled'
elif key == b'2':
self.line_attr = 'fft'
elif key == b'3':
self.line_attr = 'rolled'
elif key == b'4':
self.line_attr = 'gradient'
elif key == b'q':
exit(0)
self.set_title()
def mouse(self, button, state, x, y):
if state == GLUT_DOWN:
l = self.lines[self.nlines * y//self.height]
if button == 3:
l.roll += 1
elif button == 4:
l.roll -= 1
if l.is_teletext:
print(l.deconvolve().debug.decode('utf8')[:-1], 'er:', l.roll, l._reason)
else:
print(l._reason)
a = np.frombuffer(l._original_bytes, dtype=np.uint8)
d = np.diff(a.astype(np.int16))
md = np.mean(np.abs(d))
steps = np.floor(np.linspace(0, 2048 - 5, num=11)).astype(np.uint32)[[1, 5, 9]]
s = np.sort(a)
print(md, s[steps])
sys.stdout.flush()
def dumpline(self, x, y, teletext):
if teletext:
print('Writing to teletext.vbi')
fn = 'teletext.vbi'
else:
print('Writing to reject.vbi')
fn = 'reject.vbi'
l = self.lines[self.nlines * y // self.height]
with open(fn, 'ab') as f:
f.write(l._original_bytes)
def dumpall(self, teletext):
if teletext:
print('Writing all to teletext.vbi')
fn = 'teletext.vbi'
else:
print('Writing all to reject.vbi')
fn = 'reject.vbi'
with open(fn, 'ab') as f:
for l in self.lines:
f.write(l._original_bytes)
def set_title(self):
glutSetWindowTitle(f'{self.name} - {self.line_attr}{" (paused)" if self.pause else ""}')
def draw_slice(self, slice, r, g, b, a=1.0):
glColor4f(r, g, b, a)
glBegin(GL_LINES)
glVertex2f(slice.start, 0)
glVertex2f(slice.start, self.nlines)
glVertex2f(slice.stop, 0)
glVertex2f(slice.stop, self.nlines)
glEnd()
def draw_h_grid(self, r, g, b, a=1.0):
glColor4f(r, g, b, a)
glBegin(GL_LINES)
for x in range(self.nlines):
glVertex2f(0, x)
glVertex2f(self.config.resample_size, x)
glEnd()
def draw_bits(self, r, g, b, a=1.0):
glColor4f(r, g, b, a)
glBegin(GL_LINES)
for x in range(0, 368,8):
glVertex2f((x*8)+90, 0)
glVertex2f((x*8)+90, self.nlines)
glEnd()
def draw_freq_bins(self, n, r, g, b, a=1.0):
glColor4f(r, g, b, a)
glBegin(GL_LINES)
for x in self.config.fftbins:
glVertex2f(self.config.resample_size*x/256, 0)
glVertex2f(self.config.resample_size*x/256, self.nlines)
glEnd()
def draw_lines(self):
glEnable(GL_TEXTURE_2D)
for n,l in enumerate(self.lines[::-1]):
array = getattr(l, self.line_attr)
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, array.size, 1, 0, GL_LUMINANCE, GL_UNSIGNED_BYTE, np.clip(array, 0, 255).astype(np.uint8).tostring())
if self.tint:
if l.is_teletext:
glColor4f(0.5, 1.0, 0.7, 1.0)
else:
glColor4f(1.0, 0.5, 0.5, 1.0)
else:
glColor4f(1.0, 1.0, 1.0, 1.0)
glBegin(GL_QUADS)
glTexCoord2f(0, 1)
glVertex2f(0, n)
glTexCoord2f(0, 0)
glVertex2f(0, (n+1))
glTexCoord2f(1, 0)
glVertex2f(self.config.resample_size, (n+1))
glTexCoord2f(1, 1)
glVertex2f(self.config.resample_size, n)
glEnd()
glDisable(GL_TEXTURE_2D)
def display(self):
self.draw_lines()
if self.height / self.nlines > 3:
self.draw_h_grid(0, 0, 0, 0.25)
if self.show_grid:
if self.line_attr == 'fft':
self.draw_freq_bins(256, 1, 1, 1, 0.5)
elif self.line_attr == 'rolled' and self.width / 42 > 5:
self.draw_bits(1, 1, 1, 0.5)
elif self.line_attr == 'resampled':
self.draw_slice(self.config.start_slice, 0, 1, 0, 0.5)
glutSwapBuffers()
glutPostRedisplay()
if self.pause and not self.single_step:
time.sleep(0.1)
else:
next_lines = list(islice(self.lines_src, 0, self.nlines))
if len(next_lines) > 0:
self.lines = next_lines
self.single_step = False
================================================
FILE: tests/__init__.py
================================================
================================================
FILE: tests/test_cli.py
================================================
import unittest
from click.testing import CliRunner
import teletext.cli.teletext
import teletext.cli.training
class TestCommandTeletext(unittest.TestCase):
cmd = teletext.cli.teletext.teletext
def setUp(self):
self.runner = CliRunner()
def test_help(self):
result = self.runner.invoke(self.cmd, ['--help'])
self.assertEqual(result.exit_code, 0)
class TestCmdFilter(TestCommandTeletext):
cmd = teletext.cli.teletext.filter
class TestCmdDiff(TestCommandTeletext):
cmd = teletext.cli.teletext.diff
class TestCmdFinders(TestCommandTeletext):
cmd = teletext.cli.teletext.finders
class TestCmdSquash(TestCommandTeletext):
cmd = teletext.cli.teletext.squash
class TestCmdSpellcheck(TestCommandTeletext):
cmd = teletext.cli.teletext.spellcheck
class TestCmdService(TestCommandTeletext):
cmd = teletext.cli.teletext.service
class TestCmdInteractive(TestCommandTeletext):
cmd = teletext.cli.teletext.interactive
class TestCmdUrls(TestCommandTeletext):
cmd = teletext.cli.teletext.urls
class TestCmdHtml(TestCommandTeletext):
cmd = teletext.cli.teletext.html
class TestCmdRecord(TestCommandTeletext):
cmd = teletext.cli.teletext.record
class TestCmdVBIView(TestCommandTeletext):
cmd = teletext.cli.teletext.vbiview
class TestCmdDeconvolve(TestCommandTeletext):
cmd = teletext.cli.teletext.deconvolve
class TestCmdTraining(TestCommandTeletext):
cmd = teletext.cli.training.training
class TestCmdGenerate(TestCommandTeletext):
cmd = teletext.cli.training.generate
class TestCmdTrainingSquash(TestCommandTeletext):
cmd = teletext.cli.training.training_squash
class TestCmdShowBin(TestCommandTeletext):
cmd = teletext.cli.training.showbin
class TestCmdBuild(TestCommandTeletext):
cmd = teletext.cli.training.build
================================================
FILE: tests/test_coding.py
================================================
import unittest
import numpy as np
from teletext.coding import parity_encode, parity_decode, parity_errors, hamming8_encode, hamming8_decode, \
hamming8_correctable_errors, hamming8_uncorrectable_errors, byte_reverse, crc
class ParityEncodeTestCase(unittest.TestCase):
def _test_array(self, array: np.ndarray):
encoded = parity_encode(array)
self.assertEqual(encoded.dtype, int)
self.assertEqual(array.shape, encoded.shape, 'Encoded data has wrong shape')
#bitcounts = np.sum(np.unpackbits(encoded, axis=1), axis=1)
#self.assertTrue(all(bitcounts & 1), 'Encoded data has wrong parity.')
errors = parity_errors(encoded)
self.assertFalse(any(errors), 'Encoded data has false errors.')
decoded = parity_decode(encoded)
self.assertEqual(decoded.dtype, int)
self.assertTrue(all(decoded == array), 'Decoded data does not match original.')
for b in range(8):
oneerr = encoded ^ (1 << b)
errors = parity_errors(oneerr)
self.assertTrue(all(errors), 'Error not detected in encoded data.')
def test_full_array(self):
self._test_array(array=np.array(range(0x80), dtype=np.uint8))
def test_unit_arrays(self):
for i in range(0x80):
self._test_array(array=np.array([i], dtype=np.uint8))
def test_array_type(self):
encoded = parity_encode(np.array(range(0x80), dtype=np.uint8))
self.assertIsInstance(encoded, np.ndarray)
#self.assertEqual(encoded.dtype, np.int)
def test_list_type(self):
encoded = parity_encode(list(range(0x80)))
self.assertIsInstance(encoded, np.ndarray)
#self.assertEqual(encoded.dtype, np.int)
def test_unit_array_type(self):
encoded = parity_encode(np.array([0], dtype=np.uint8))
self.assertIsInstance(encoded, np.ndarray)
#self.assertEqual(encoded.dtype, np.int)
def test_unit_list_type(self):
encoded = parity_encode([0])
self.assertIsInstance(encoded, np.ndarray)
#self.assertEqual(encoded.dtype, np.int)
def test_int_type(self):
encoded = parity_encode(0)
#self.assertIsInstance(encoded, np.int64)
def test_full_list(self):
data = list(range(0x80))
encoded = parity_encode(data)
self.assertEqual(encoded.shape, (len(data),), 'Encoded data has wrong shape')
def test_unit_list(self):
data = [0]
encoded = parity_encode(data)
self.assertEqual(encoded.shape, (1, ), 'Encoded data has wrong shape')
def test_ints(self):
for i in range(0x80):
encoded = parity_encode(i)
errors = parity_errors(encoded)
self.assertFalse(errors, 'Encoded data has false errors.')
decoded = parity_decode(encoded)
self.assertEqual(decoded, i, 'Decoded data does not match original.')
for b in range(8):
oneerr = encoded ^ (1 << b)
errors = parity_errors(oneerr)
self.assertTrue(errors, 'Error not detected in encoded data.')
class Hamming8TestCase(unittest.TestCase):
def test_all(self):
def h8_manual(d):
d1 = d & 1
d2 = (d >> 1) & 1
d3 = (d >> 2) & 1
d4 = (d >> 3) & 1
p1 = (1 + d1 + d3 + d4) & 1
p2 = (1 + d1 + d2 + d4) & 1
p3 = (1 + d1 + d2 + d3) & 1
p4 = (1 + p1 + d1 + p2 + d2 + p3 + d3 + d4) & 1
return (p1 | (d1 << 1) | (p2 << 2) | (d2 << 3)
| (p3 << 4) | (d3 << 5) | (p4 << 6) | (d4 << 7))
for i in range(0x10):
self.assertTrue(hamming8_encode(i) == h8_manual(i))
data = np.arange(0x10, dtype=np.uint8)
encoded = hamming8_encode(data)
self.assertTrue(all(hamming8_decode(encoded) == data))
self.assertTrue(not any(hamming8_correctable_errors(encoded)))
self.assertTrue(not any(hamming8_uncorrectable_errors(encoded)))
for b1 in range(8):
oneerr = encoded ^ (1 << b1)
self.assertTrue(all(hamming8_decode(oneerr) == data))
self.assertTrue(all(hamming8_correctable_errors(oneerr)))
self.assertTrue(not any(hamming8_uncorrectable_errors(oneerr)))
for b2 in range(8):
if b2 != b1:
twoerr = oneerr ^ (1 << b2)
self.assertTrue(not any(hamming8_correctable_errors(twoerr)))
self.assertTrue(all(hamming8_uncorrectable_errors(twoerr)))
class Reverse8TestCase(unittest.TestCase):
def test_all(self):
for i in range(256):
reversed = 0
for n in range(8):
reversed |= ((i>>n)&1) << (7-n)
self.assertEqual(byte_reverse(i), reversed)
class CRCTestCase(unittest.TestCase):
def test_all(self):
self.assertEqual(crc(0, 0), 0)
self.assertEqual(crc(0x55, 0xaa), 0xaa5e)
================================================
FILE: tests/test_elements.py
================================================
import itertools
import unittest
from teletext.elements import *
class TestElement(unittest.TestCase):
cls = Element
shape = (2,)
sized = False
needsmrag = False
def make_element(self, array):
args = []
if not self.sized:
args.append(self._array.shape)
args.append(array)
if self.needsmrag:
mrag = Mrag()
mrag.magazine = 1
mrag.row = 0
args.append(mrag)
return self.cls(*args)
def setUp(self):
self._array = np.zeros(self.shape, dtype=np.uint8)
self.element = self.make_element(self._array)
def test_type(self):
self.assertIsInstance(self.element, self.cls)
def test_wrong_shape(self):
with self.assertRaises(IndexError):
array = np.zeros((1,1), dtype=np.uint8)
self.make_element(array)
def test_getitem(self):
for i, j in itertools.product(range(self.shape[0]), range(256)):
self._array[i] = j
self.assertEqual(self.element[i], j)
self.assertEqual(self.element[i], self._array[i])
def test_setitem(self):
for i, j in itertools.product(range(self.shape[0]), range(256)):
self.element[i] = j
self.assertEqual(self.element[i], j)
self.assertEqual(self.element[i], self._array[i])
def test_repr(self):
self.assertEqual(repr(self.element), f'{self.cls.__name__}({repr(self._array)})')
def test_errors(self):
with self.assertRaises(NotImplementedError):
self.element.errors()
def test_bytes(self):
self.assertEqual(self._array.tobytes(), self.element.bytes)
class TestElementParity(TestElement):
cls = ElementParity
shape = (2, )
def test_errors(self):
self.assertTrue(all(self.element.errors))
self._array[:] = 0x20 # correct parity
self.assertFalse(any(self.element.errors))
class TestElementHamming(TestElementParity):
cls = ElementHamming
shape = (2, )
def test_errors(self):
self.assertTrue(all(self.element.errors))
self._array[:] = 0x15 # correct hamming8
self.assertFalse(any(self.element.errors))
class TestMrag(TestElementHamming):
cls = Mrag
shape = (2, )
sized = True
def test_magazine(self):
for i in range(1, 9):
self._array[:] = 0
self.element.magazine = i
self.assertEqual(self.element.magazine, i)
self.assertTrue(any(self._array))
def test_row(self):
for i in range(32):
self._array[:] = 0
self.element.row = i
self.assertEqual(self.element.row, i)
self.assertTrue(any(self._array))
class TestDisplayable(TestElementParity):
cls = Displayable
shape = (11, )
def test_place_string(self):
self.element.place_string('Hello World', x=0)
self.assertFalse(any(self.element.errors))
class TestPage(TestElementHamming):
cls = Page
shape = (2,)
class TestHeader(TestPage):
cls = Header
shape = (40,)
sized = True
class TestPageLink(TestPage):
cls = PageLink
shape = (6,)
sized = True
needsmrag = True
class TestDesignationCode(TestElementHamming):
cls = DesignationCode
shape = (1, )
def test_set_dc(self):
for i in range(16):
self.element.dc = i
self.assertEqual(self.element.dc, i)
class TestFastext(TestDesignationCode):
cls = Fastext
shape = (40,)
sized = True
needsmrag = True
@unittest.skip("Not implemented yet.")
def test_errors(self):
pass # TODO
================================================
FILE: tests/test_file.py
================================================
import io
import unittest
import numpy as np
from teletext.file import FileChunker
class TestChunker(unittest.TestCase):
def setUp(self):
self.data = np.arange(0, 256, dtype=np.uint8)
self.file = io.BytesIO(self.data.tobytes())
def test_basic(self):
result = list(FileChunker(self.file, 1))
self.assertEqual(len(result), len(self.data))
for n in range(256):
self.assertEqual(result[n], (n, bytes([n])))
def test_step(self):
result = list(FileChunker(self.file, 1, step=2))
self.assertEqual(len(result), len(self.data[::2]))
for n in range(128):
self.assertEqual(result[n], (n*2, bytes([n*2])))
================================================
FILE: tests/test_mp.py
================================================
from multiprocessing import current_process
import unittest
from functools import wraps
from itertools import count, islice
import os
import sys
import time
from teletext.mp import itermap, PureGeneratorPool, _PureGeneratorPoolSingle, _PureGeneratorPoolMP
from .test_sigint import ctrl_c
def multiply(it, a):
for x in it:
yield x*a
def null(it, a):
for x in it:
yield (x, a)
callcounter = 0
def callcount(it):
global callcounter
callcounter += 1
for x in it:
yield callcounter
def crashy(it):
for x in it:
if x:
raise ValueError('Crashed on purpose.')
else:
yield x
def early_crash(it):
raise ValueError('Crashed early on purpose.')
def not_generator(it):
return 23
class TestMPSingle(unittest.TestCase):
procs = 1
desired_type = _PureGeneratorPoolSingle
def setUp(self):
global callcounter
callcounter = 0
def test_single(self):
input = list(range(100))
expected = list(multiply(input, 3))
result = list(itermap(multiply, input, self.procs, 3))
self.assertListEqual(result, expected)
def test_called_once_single(self):
result = list(itermap(callcount, [None] * (self.procs + 1), processes=self.procs))
self.assertListEqual(result, [1] * (self.procs + 1))
def test_reuse(self):
input = list(range(100))
expected = list(multiply(input, 3))
with PureGeneratorPool(multiply, self.procs, 3) as pool:
self.assertIsInstance(pool, self.desired_type)
result = list(pool.apply(input[:50]))
self.assertListEqual(result, expected[:50])
result = list(pool.apply(input[50:]))
self.assertListEqual(result, expected[50:])
def test_called_once_reuse(self):
with PureGeneratorPool(callcount, processes=self.procs) as pool:
for n in range(self.procs + 1): # ensure at least one process is used twice
result = list(pool.apply([None]))
self.assertListEqual(result, [1])
def _crashing_iter(self, n):
with self.assertRaises(ChildProcessError if self.procs > 1 else ValueError):
list(itermap(crashy, ([False]*n) + [True], processes=self.procs))
def test_crashing_iter(self):
self._crashing_iter(0)
self._crashing_iter(self.procs + 1)
self._crashing_iter(40)
def test_early_crash(self):
with self.assertRaises(ChildProcessError if self.procs > 1 else ValueError):
list(itermap(early_crash, ([False]*3), self.procs))
def test_not_generator(self):
with self.assertRaises(ChildProcessError if self.procs > 1 else TypeError):
list(itermap(not_generator, ([False]*3), self.procs))
def test_too_many_args(self):
with self.assertRaises(ChildProcessError if self.procs > 1 else TypeError):
list(itermap(multiply, ([False]*3), self.procs, 3, 4))
class TestMPMulti(TestMPSingle):
procs = 2
desired_type = _PureGeneratorPoolMP
def test_unpickleable_function(self):
with self.assertRaises(AttributeError):
list(itermap(lambda x: x, ([False] * 3), self.procs))
def test_unpickleable_item_in_args(self):
with self.assertRaises(AttributeError):
list(itermap(null, ([None]*10), self.procs, lambda x: x))
def test_unpickleable_item_in_iter(self):
with self.assertRaises(AttributeError):
list(itermap(null, ([None]*10) + [lambda x: x], self.procs, None))
def test_empty_iter(self):
result = list(itermap(callcount, [], processes=self.procs))
self.assertListEqual(result, [])
class TestMPMultiSigInt(unittest.TestCase):
pool_size = 4
def items(self):
with PureGeneratorPool(multiply, self.pool_size, 1) as pool:
self.pool = pool
yield from pool.apply(islice(count(), 2000))
def test_sigint_to_self(self):
result = self.items()
next(result)
with self.assertRaises(KeyboardInterrupt):
ctrl_c(os.getpid())
@unittest.skipIf(sys.platform.startswith('win'), "Can't send ctrl-c to an individual process on Windows")
def test_sigint_to_child(self):
result = self.items()
next(result)
with self.assertRaises(ChildProcessError):
for i in range(self.pool_size):
ctrl_c(self.pool._procs[i].pid)
for r in result:
pass
================================================
FILE: tests/test_packet.py
================================================
import itertools
import unittest
from teletext.packet import *
class TestPacket(unittest.TestCase):
packet = Packet()
def setUp(self):
pass
def test_type(self):
self.packet.mrag.row = 0
self.assertEqual(self.packet.type, 'header')
self.packet.mrag.row = 1
self.assertEqual(self.packet.type, 'display')
self.packet.mrag.row = 27
self.assertEqual(self.packet.type, 'fastext')
self.packet.mrag.row = 28
self.assertEqual(self.packet.type, 'page enhancement')
self.packet.mrag.row = 29
self.assertEqual(self.packet.type, 'magazine enhancement')
self.packet.mrag.row = 31
self.assertEqual(self.packet.type, 'independent data')
self.packet.mrag.row = 30
self.assertEqual(self.packet.type, 'independent data')
self.packet.mrag.magazine = 8
self.assertEqual(self.packet.type, 'broadcast')
================================================
FILE: tests/test_sigint.py
================================================
import os
import signal
import sys
import time
import unittest
from teletext.sigint import SigIntDefer
def ctrl_c(pid):
if sys.platform.startswith('win'):
# Note: on Windows this doesn't get delivered immediately.
os.kill(pid, signal.CTRL_C_EVENT)
time.sleep(0.05)
else:
os.kill(pid, signal.SIGINT)
class TestSigInt(unittest.TestCase):
def test_ctrl_c(self):
with self.assertRaises(KeyboardInterrupt):
ctrl_c(os.getpid())
def test_interrupt(self):
with self.assertRaises(KeyboardInterrupt):
with self.assertRaises(ValueError):
with SigIntDefer() as s:
self.assertFalse(s.fired)
ctrl_c(os.getpid())
self.assertTrue(s.fired)
raise ValueError
================================================
FILE: tests/test_spellcheck.py
================================================
import unittest
from teletext.spellcheck import *
from teletext.elements import Displayable
class TestSpellCheck(unittest.TestCase):
def setUp(self):
self.sc = SpellChecker(language='en_GB')
def test_case_match(self):
src = 'AaAaA'
word = 'bbbbb'
self.assertEqual(self.sc.case_match(word, src), 'BbBbB')
def test_suggest(self):
# correctly spelled word should be unchanged
self.assertEqual(self.sc.suggest('hello'), 'hello')
# incorrectly spelled word with known substitutions should be fixed
self.assertEqual(self.sc.suggest('dello'), 'hello')
def test_spellcheck(self):
d = Displayable((17,), 'dello dello dello'.encode('ascii'))
self.sc.spellcheck(d)
self.assertEqual(d.to_ansi(colour=False), 'hello hello hello')
================================================
FILE: tests/test_stats.py
================================================
import unittest
from teletext.stats import *
class TestStatsList(unittest.TestCase):
def test_str(self):
l = StatsList()
l.append('a')
l.append('b')
self.assertEqual(str(l), 'ab')
================================================
FILE: tests/test_subpage.py
================================================
import unittest
import numpy as np
from teletext.subpage import Subpage
class SubpageTestCase(unittest.TestCase):
def test_checksum(self):
p = Subpage()
self.assertEqual(0xe23d, p.checksum)
p = Subpage(prefill=True)
self.assertEqual(0xe23d, p.checksum)
================================================
FILE: tests/vbi/__init__.py
================================================
================================================
FILE: tests/vbi/test_line.py
================================================
import os
import unittest
import numpy as np
from teletext.file import FileChunker
from teletext.vbi.line import Line
from teletext.vbi.config import Config
class LineTestCase(unittest.TestCase):
def noisegen(self, max_loc, max_scale):
for n in range(10):
for loc in range(0, max_loc+1, max(1, max_loc//8)):
for scale in range(0, max_scale+1, max(1, max_scale//8)):
yield (
np.clip(np.random.normal(loc, scale, size=(2048,)), 0, 255).astype(np.uint8).tobytes(),
{'loc':loc, 'scale':scale}
)
def setUp(self):
Line.configure(Config(), force_cpu=True)
def test_empty_rejection(self):
lines = ((Line(data), params) for data, params in self.noisegen(256, 8))
lines = ((line, params) for line, params in lines if line.is_teletext)
for line, params in lines:
self.assertFalse(line.is_teletext, f'Noise interpreted as teletext: {params}')
@unittest.expectedFailure
def test_known_teletext(self):
try:
with open(os.path.join(os.path.dirname(__file__), 'data', 'teletext.vbi'), 'rb') as f:
lines = (Line(data, number) for number, data in FileChunker(f, 2048))
for line in lines:
self.assertTrue(line.is_teletext, f'Line {line._number} false negative.')
except FileNotFoundError:
self.skipTest('Known teletext data not available.')
@unittest.expectedFailure
def test_known_reject(self):
try:
with open(os.path.join(os.path.dirname(__file__), 'data', 'reject.vbi'), 'rb') as f:
lines = (Line(data, number) for number, data in FileChunker(f, 2048))
for line in lines:
self.assertFalse(line.is_teletext, f'Line {line._number} false positive.')
except FileNotFoundError:
self.skipTest('Known reject data not available.')
================================================
FILE: tests/vbi/test_patterncuda.py
================================================
import pathlib
import unittest
import numpy as np
from teletext.vbi.pattern import Pattern
try:
from teletext.vbi.patterncuda import PatternCUDA
class PatternCUDATestCase(unittest.TestCase):
def setUp(self):
p = pathlib.Path(__file__).parent.parent.parent / 'teletext' / 'vbi' / 'data' / 'vhs' / 'parity.dat'
self.pattern = Pattern(p)
self.patterncuda = PatternCUDA(p)
def test_equal_to_cpu(self):
arr = np.arange(256, dtype=np.uint8)
a = self.pattern.match(arr)
b = self.patterncuda.match(arr)
self.assertTrue(all(a==b), 'CPU and CUDA pattern matching produced different results.')
except ModuleNotFoundError as e:
if e.name != 'pycuda':
raise
================================================
FILE: tests/vbi/test_patternopencl.py
================================================
import pathlib
import unittest
import numpy as np
from teletext.vbi.pattern import Pattern
try:
from teletext.vbi.patternopencl import PatternOpenCL
class PatternOpenCLTestCase(unittest.TestCase):
def setUp(self):
p = pathlib.Path(__file__).parent.parent.parent / 'teletext' / 'vbi' / 'data' / 'vhs' / 'parity.dat'
self.pattern = Pattern(p)
self.patternopencl = PatternOpenCL(p)
def test_equal_to_cpu(self):
arr = np.arange(256, dtype=np.uint8)
a = self.pattern.match(arr)
b = self.patternopencl.match(arr)
#self.assertTrue(all(a==b), 'CPU and OpenCL pattern matching produced different results.')
self.assertEqual(a.tolist(), b.tolist(), 'CPU and OpenCL pattern matching produced different results.')
except ModuleNotFoundError as e:
if e.name != 'pyopencl':
raise
================================================
FILE: tests/vbi/test_training.py
================================================
import io
import unittest
import numpy as np
from teletext.vbi.training import *
from teletext.vbi.config import Config
class TrainingTestCase(unittest.TestCase):
def setUp(self):
pass
@unittest.skip
def test_split(self):
files = [io.BytesIO() for _ in range(256)]
pattern = PatternGenerator.load_pattern()
max_n = 10
data = [(n, np.unpackbits(pattern[n:n+PatternGenerator.pattern_length][::-1])[::-1]) for n in range(max_n)]
split(data, files)
pattern_bits = np.unpackbits(pattern[:max_n+PatternGenerator.pattern_length][::-1])[::-1]
patterns_present = set()
for x in range(len(pattern_bits) - 23):
patterns_present.add(
np.packbits(pattern_bits[x:x+24][::-1])[::-1].tobytes()
)
for f in files[:1]:
arr = np.frombuffer(f.getvalue(), dtype=np.uint8).reshape(-1, 27)
for l in arr:
# Assert that pattern matches samples.
self.assertTrue(all(l[:3] == np.packbits(l[3:][::-1])[::-1]))
# Assert that pattern is a pattern we actually put in to split.
self.assertIn(l[:3].tobytes(), patterns_present)