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 ================================================ MainWindow 0 0 1056 654 Teletext Editor false 0 QLayout::SetDefaultConstraint 1 1 1 1 QFrame::Panel 1 true Qt::AlignCenter 0 0 855 608 0 0 background-color: rgb(0, 0, 0); 0 0 0 0 0 QQuickWidget::SizeViewToRootObject 0 0 1056 20 File View Zoom Service 1 0 2 2 2 2 QFrame::Panel QAbstractItemView::NoEditTriggers false QAbstractItemView::NoDragDrop Qt::MoveAction QAbstractItemView::ExtendedSelection QAbstractItemView::SelectItems true true false Import T42 Import Zoom In Ctrl++ Zoom Out Ctrl+- 1x 2x 3x 4x true CRT Effect true Reveal QQuickWidget QWidget
QtQuickWidgets/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)